# Channel Share Card Spec

> Status: current
> Canonical path: `specs/message/private-channel-share-card.md`
> Related specs: [`specs/message/scope-model.md`](scope-model.md), [`specs/message/search.md`](search.md)
> Scope: channel share-card creation, join/open/revoke behavior, owner-only revoke/delete affordance

## Goal

Let a channel owner post a join card for an ordinary public or private channel into another joined scope. The source channel owner can also share an existing card onward to another joined channel instead of generating an unrelated duplicate card. A workspace member who can read the card can click **Join** to join the source channel. After joining, the same card shows **Open**. The source channel owner can revoke the card, which blocks future non-member joins through that card and every re-shared copy without removing existing members.

## Product behavior

- Only the current owner of the source channel can create or revoke a share card.
- Only the current owner of the source channel can share an existing card onward.
- The source must be an ordinary public or private channel scope; `#all`, DM scopes, and reply-thread scopes are not share-card sources.
- V1 creation UI lives in the source channel members pane and posts to another joined, non-archived target channel scope.
- Card UI exposes **Share** for the source owner on active cards and posts the existing card to another joined, non-archived channel; the current card channel and source channel are not offered as targets.
- Card visibility is inherited from the scope where the `channel_share_card` message is posted.
- A non-member who can read an active card sees **Join**.
- A member of the source channel sees **Open**, including after the card is revoked.
- A revoked card shows **Revoked** to non-members and cannot add new members from any copy.
- A card is **Unavailable** if the source channel is deleted, is no longer public/private, is not an ordinary channel, or has no owner.

## Storage model

Channel share cards are message-owned transcript items with domain action state:

- `scope_messages` stores the visible target-scope message with `message_type = 'channel_share_card'`.
- The message content and `metadata.channelShare` carry only a bounded display fallback for immediate timeline rendering.
- `scope_share_cards` stores the authoritative lifecycle and provenance.
- `scope_share_cards.scope_id` / `message_id` identify the target scope and channel-share message.
- `scope_share_cards.source_scope_id` identifies the channel being shared.
- `scope_share_cards.source_message_id` identifies the root card message when a card is shared onward; all rows with the same root share one revocation lifecycle.
- `scope_share_cards.target_scope_id` identifies the target surface where the card was posted.
- `scope_share_cards.created_by_member_id`, `revoked_at`, and `revoked_by_member_id` are structured lifecycle fields.
- `scope_share_cards.payload` may hold bounded display snapshot data, but lifecycle and authorization state must not depend on message metadata.

Join/revoke APIs read `scope_share_cards` and verify that the viewer can read the owning `channel_share_card` message through the shared SQL readable predicate / visible-row mapper.

## API

- `POST /api/channels/:channelId/share-card`
  - request: `{ "targetChannelId": "chn_target" }`
  - returns the created `channel_share_card` message
- `POST /api/channel-share-cards/:messageId/share`
  - request: `{ "targetChannelId": "chn_target" }`
  - shares an existing active card to another channel and returns the created `channel_share_card` message
- `GET /api/channel-share-cards/:messageId`
  - returns viewer-scoped card state
- `POST /api/channel-share-cards/:messageId/join`
  - joins the source channel and returns normal channel metadata
- `POST /api/channel-share-cards/:messageId/revoke`
  - revokes the card and returns updated card state

Card status values are `joinable`, `joined`, `revoked`, and `unavailable`.

## Privacy and authorization

- A visible card may reveal only the source channel id, name, description, and viewer-scoped card state.
- The card never grants pre-join access to source channel history, tasks, members, unread state, notification settings, or private agents.
- Source ownership is checked at action time, so former owners cannot revoke cards after ownership transfer unless they are still the current owner.
- Having a card URL or message id is not sufficient; the caller must be able to read the anchor message's scope.
- Member display and card state must be scoped by authenticated workspace and viewer membership before returning public/self/owner-private rows.
