# Message Pin 与置顶消息规范

> Status: canonical-spec
> Canonical path: `specs/message/pin.md`
> Related specs: [Message Pin 实现设计](pin-design.md), [scope 消息模型](scope-model.md), [Scope Target / Access Contract](scope-target-access-contract.md), [服务号规范](../service-account/service-account.md), [消息操作规范](actions.md), [Reaction 规范](reaction.md), [Rewind 规范](rewind.md), [Message Search](search.md), [../storage/storage.md](../storage/storage.md)
> Scope: channel / reply thread / DM / service-account 当前会话目标内的 message-level Pins tab/list 与独立“置顶消息”顶部 banner 产品语义、权限、API、存储、realtime/sync、UI 与测试要求
> Non-goals: personal saved/later、个人 pin 收藏状态、top notice 汇总到 parent channel/DM、系统消息时间线提示、跨外部 IM 平台 pin 桥接、完整 delete/recall tombstone 体验

## 1. 产品语义

AgentTeam 把 **Pin** 与 **置顶消息（top notice / top banner）** 拆成两个产品概念：

- **Pin**：把消息加入当前会话目标的 shared Pins tab/list，类似 Slack/飞书的 Pins 列表。Pin 不常驻展示消息 preview，不等同于“置顶到会话顶部”。
- **置顶消息 / top notice**：当前会话顶部的单条 banner/notice，用于把一条重要消息显式放到聊天区上方。它不是 Pins tab 的最新项，也不由 Pins 列表自动推导。

核心决策：

- Pin 和 **Remove from Pins / Unpin for everyone** 是 **shared conversation state**；同一 target 中有权限的成员看到相同 active pin set。
- 置顶消息和 **Remove top notice / 取消置顶** 也是 shared conversation state，但每个 target 最多一条 active top notice。
- Pin 与置顶消息可以指向同一条 message，也可以各自独立；一个 message 被 Pin 不代表它是顶部置顶，反之亦然。
- 任何能看见目标消息、并且对该 target 具备写入/member 权限的用户都可以 Pin、Remove from Pins、Set top notice 或 Remove top notice。
- `pinned_by` / `set_by` 只用于展示和审计，不限制 shared remove；原操作者不是唯一可以移除的人。
- Pin 的完整列表必须按 `pinned_at DESC, id DESC` 展示所有当前 viewer 可见 pins，并支持分页或无限滚动。
- 置顶 banner 展示当前 active top notice 的最新可见消息内容；viewer 可以只为自己 hide/ack 当前 banner，不修改 shared top notice，也不影响其他用户。
- Top notice hide/ack 针对具体 `top_notice_id`，同一 target 后续换成另一条置顶消息时必须重新显示。
- Pin list 与 top notice 都归属当前请求 target；channel、DM、reply thread 和 service-account 各自维护独立状态，不向 parent target 汇总。
- Pin/top/unpin/untop/hide 不在聊天时间线写系统消息，不推进普通 message cursor，不产生 unread。
- Pin/top/unpin/untop/hide 必须幂等、no-op friendly。
- Pin 与 top notice 都作为 sidecar metadata 存储，不修改 message body。
- 消息后续编辑后，Pins list 与 top notice banner 展示该消息的最新可见内容，而不是操作时的正文快照。
- 已删除、已撤回或当前 viewer 不可见的消息，不出现在 Pins list 与 top notice banner 中。

## 2. 概念模型

### 2.1 Target 与 scope

本规范中的 target 是用户当前正在浏览或操作的会话目标：

- channel target：Pin list / top notice 归属该 channel 对应的 target scope；
- reply thread target：Pin list / top notice 归属该 reply-thread scope；
- DM target：Pin list / top notice 归属该 DM scope；
- service-account target：StorageScope、ReadScope、PinListScope 与 TopNoticeScope 均归属该账号的 message_scope_id。

读写必须使用 `scope-target-access-contract.md` 中的 typed scope roles，并在 Pin 场景额外解析 **Pin list scope**：

- `StorageScope` / `ReadScope`：请求 target 的消息读写 scope，用于校验 actor 能读取 target 和目标 message；
- `PinListScope`：`scope_message_pins.scope_id` 的归属，始终等于请求 target；
- `TopNoticeScope`：`scope_message_top_notices.scope_id` 的归属，始终等于请求 target；
- `PermissionBasis`：说明 actor 因何被允许对该 target 执行 shared Pin/Top 操作。

Reply-thread Pin 写入、列表读取和分页都使用该 reply-thread scope。服务端必须按请求 scope 过滤，不能先读取 parent Pins list 再由客户端筛选。Parent channel/DM 不汇总 child-thread Pins 或 top notice。

### 2.2 Pin、Top notice 与其他消息附属状态

Pin、top notice 和 reaction 都是 message sidecar state；但产品语义不同：

- reaction 是轻量反馈，可按 actor/emoji 聚合；
- Pin 是 target-level curated list，表示该 target 中所有成员共享的一组重点消息；
- top notice 是 target-level singleton banner，表示当前 target 顶部展示的一条重点消息；
- personal top notice hide 是 viewer 对当前 top notice banner 的展示偏好，不是 personal pin、personal unpin、reaction 或 message mutation；
- Pin/top notice/hide 都不进入 agent-facing activity source，不唤醒 agent，不写普通 delivery outbox；
- Pin/top notice/hide 都不影响 search ranking、message freshness gate、read cursor 或 unread count。

### 2.3 排序与 singleton

Pins list 的 canonical ordering：

1. `pinned_at DESC`；
2. 当 server timestamp 精度下出现并列时，用稳定 tie-breaker `id DESC`。

完整 Pins list 展示排序后的所有 **当前 viewer 可见** pinned messages。重复 Pin 同一 message 是 no-op，不会刷新 `pinned_at`，也不会把该 message 重新移动到列表顶部。

Top notice 是每个 target 的 singleton。设置新 top notice 会替换同 scope 的旧 top notice；重复设置同一 message 是 no-op，不刷新 `set_at`。Viewer hide 只匹配当前 `top_notice_id`，旧 hide 不能影响后续 top notice。

## 3. 权限与可见性

### 3.1 谁可以查看

查看 target Pins list 或 top notice 的 actor 必须能读取该 target：

- channel：当前可读该 channel scope 的 member；
- DM：DM participant；
- reply thread：当前可读该 reply-thread scope 的 member/participant，或通过现有 thread access policy 被授权的 actor；
- service-account：拥有目标 scope active direct membership 的 Human；服务号 audience projector 固定为 Workspace 全部 active Human 维护该 membership；
- service/agent caller：必须通过与 human caller 等价的 target resolver 和可见性 predicate，不得因为服务身份隐式绕过 target 权限。

List/top notice endpoint 还必须对每条 message 应用 shared readable predicate / visible-row mapper。Actor 可读 target 但不可读某条 message 时，该 Pin/top notice 不出现在返回值中。

### 3.2 谁可以 Pin 或设置 top notice

Shared Pin/top 写入需要同时满足：

1. actor 能读取 target；
2. actor 能读取目标 message；
3. 对 Pin：目标 message 属于请求 target 的 read scope，且 Pin row 写入该 target 解析出的 Pin list scope；对 top notice：目标 message 属于同一个 target storage/read scope；
4. actor 对该 target 具备 write/member 权限。

Conversational target 的默认 policy 是 `members`：满足上述条件的 target member/participant 可以 Pin、Remove from Pins、Set top notice、Remove top notice。Workspace-owned service-account target 使用 management_kind=workspace + account active + `service_account_manager` + target message read 作为 shared mutation basis。Platform-owned service-account target 提供 personal conversation pin，并由 platform broadcast 维持 created message lifecycle。

服务端不得把 `pinned_by_member_id`、`set_by_member_id`、消息作者或 UI 客户端判断当成授权依据。

### 3.3 谁可以 hide top notice banner for me

Personal hide/ack 是读取者自己的 banner 展示状态，不是 shared remove。写入 personal hide state 需要满足：

1. actor 能读取 target；
2. actor 能读取当前 active top notice 的目标 message；
3. 如果请求携带 `topNoticeId` precondition，必须与当前 active top notice 匹配。

Personal hide 不要求 actor 具备 target write/member 权限；只要 actor 可以合法看到该 target 和该 top notice message，就可以为自己隐藏 banner。若 active top notice 不存在、message 对 actor 不可见、或 precondition 指向已过期的 top notice instance，服务端必须返回 no-op/冲突语义且不得泄漏不可见 top notice 是否存在。

### 3.4 不可见、删除和撤回

读路径规则：

- 已删除、已撤回、被 rewind 删除、或对当前 viewer 不可见的 message 不出现在 Pins list/top notice banner 中；
- 若某 target 所有 active pins 都不可见，Pins list 对该 viewer 为空；
- 若 active top notice message 对当前 viewer 不可见，top notice banner 对该 viewer 隐藏；
- 服务端不得泄漏被过滤 sidecar row 的 message id、作者、正文或 metadata。

写路径规则：

- 不能 Pin 或 top 已删除、已撤回、已 rewind 删除或当前 actor 不可见的 message；
- 普通用户不能通过 remove API 操作自己不可见的 pinned/top message；
- 系统 cleanup、delete/recall/rewind 流程可以绕过 human visibility，用系统权限移除或 tombstone 相关 sidecar rows。

## 4. 存储模型

### 4.1 `scope_message_pins`

Pin 当前状态使用独立 sidecar table，而不是修改 `scope_messages` 或 message body。

字段：

| 字段                        | 含义                                                       |
| --------------------------- | ---------------------------------------------------------- |
| `id`                        | pin row id，用于稳定排序 tie-breaker、审计引用             |
| `workspace_id`              | workspace 边界                                             |
| `scope_id`                  | pin 归属请求 target scope；必须与目标 message 所属 scope 一致 |
| `message_id`                | 被 pin 的 `scope_messages.id`                              |
| `pinned_by_member_id`       | 创建 active pin 的 member；展示/审计用，不参与 remove 授权 |
| `pinned_at`                 | server 生成的 pin 时间，也是列表主排序键                   |
| `created_at` / `updated_at` | 行级维护字段；幂等重复 pin 不应更新这些字段来表达业务变化  |

约束：

- `(scope_id, message_id)` 唯一，保证同一 Pin list scope 内同一 message 只有一个 active pin；
- `message_id` 必须指向 live `scope_messages.id`；
- `scope_id` 必须等于目标 message 所属 scope；
- 写入时必须在同一事务内校验 workspace、请求 target、Pin list scope、message scope 一致性；
- `pinned_by_member_id` 可以保留已离开成员的 id，用于历史展示降级。

必要索引：

- `(workspace_id, scope_id, pinned_at, id)`：list 读取；
- `(workspace_id, scope_id, message_id)` unique：幂等 Pin 与 remove lookup；
- `(workspace_id, message_id)`：按 message 聚合 row marker；
- `(workspace_id, pinned_by_member_id, pinned_at)`：低频审计或 profile drill-down。

### 4.2 `scope_message_top_notices`

Top notice 当前状态使用独立 singleton sidecar table。

字段：

| 字段                        | 含义                                                                  |
| --------------------------- | --------------------------------------------------------------------- |
| `id`                        | top notice row id，也是 viewer hide 的 instance key                   |
| `workspace_id`              | workspace 边界                                                        |
| `scope_id`                  | top notice 归属 target/storage scope                                  |
| `message_id`                | 被展示在顶部 banner 的 `scope_messages.id`                            |
| `set_by_member_id`          | 设置 active top notice 的 member；展示/审计用，不参与 remove 授权     |
| `set_at`                    | server 生成的设置时间                                                 |
| `created_at` / `updated_at` | 行级维护字段；幂等重复设置同一 message 不应更新这些字段来表达业务变化 |

约束：

- `scope_id` 唯一，保证每个 target 最多一个 active top notice；
- `(scope_id, message_id)` 唯一，保证同一 target 中同一 message 的幂等设置；
- `message_id` 必须指向 live `scope_messages.id`；
- 替换 top notice 时应删除旧 row 或以等价 active-state 方式保证 singleton；
- `set_by_member_id` 可以保留已离开成员的 id，用于历史展示降级。

### 4.3 `scope_message_top_notice_hides`

Personal top notice hide state 使用独立 per-viewer sidecar table。该表只表达“某 viewer 对某 top notice instance 不想在 banner 中看到”，不表达 personal unpin 或 personal Pins list 过滤。

字段：

| 字段                        | 含义                                                       |
| --------------------------- | ---------------------------------------------------------- |
| `id`                        | hide row id，用于审计和同步引用                            |
| `workspace_id`              | workspace 边界                                             |
| `scope_id`                  | hide 生效的 target/storage scope                           |
| `top_notice_id`             | hide 时匹配的 active top notice row id                     |
| `message_id`                | 被隐藏 banner 的 `scope_messages.id`                       |
| `hidden_by_member_id`       | 为自己隐藏 banner 的 member                                |
| `hidden_at`                 | server 生成的隐藏时间                                      |
| `created_at` / `updated_at` | 行级维护字段；幂等重复 hide 不应更新这些字段来表达业务变化 |

约束：

- hidden row 的匹配键必须包含 `top_notice_id`，避免同一 message 再次成为 top notice 时被旧 hide 吞掉；
- 同一 actor 对同一 top notice instance 重复 hide 必须幂等；
- “Show top notice again” 可以删除 hidden row；reset 删除 `(workspace_id, scope_id, hidden_by_member_id)` 下所有 top notice hides；
- shared remove、message delete/recall、target 删除或成员删除后，可以通过 FK cascade 或异步清理 stale hidden rows；未清理的 stale rows 也必须被当前 active top notice instance 匹配规则自然忽略。

必要索引：

- `(workspace_id, scope_id, hidden_by_member_id, hidden_at)`：banner 针对当前 viewer 读取 hidden state；
- `(top_notice_id, hidden_by_member_id)` unique：幂等 hide lookup；
- `(workspace_id, message_id, hidden_by_member_id)`：低频调试或清理。

### 4.4 不写入的事实

Pin、top notice、shared remove、personal hide 写路径不得产生以下 durable facts：

- 不向 `scope_messages` 插入 `pin_added` / `top_notice_set` / `pin_removed` 系统消息；
- 不修改目标 message body、markdown、attachments 或 author；
- 不推进 `scope_messages.seq`、workspace global message seq、read cursor 或 unread cursor；
- 不写 agent-facing `scope_activity_events`；
- 不写普通 message `scope_fanout_outbox`；
- 不把 Pin/top 文案单独写入 search projection；
- personal hide 不修改 shared top notice row、目标 message、row marker、search projection 或其他用户可见状态。

Pins list 与 top notice banner 需要展示内容时，应 join 或复用 message read model，读取当前可见 message 内容、作者、attachments summary、edited state、row-level metadata，以及当前 viewer 的 top notice hidden state。

### 4.5 与 message edit/delete/recall/rewind 的关系

- Message edit：sidecar row 不变；Pins list/top notice banner 通过 message read model 显示最新内容。
- Message delete/recall：可以在 delete/recall 事务中删除相关 active pin/top rows，也可以保留 sidecar row 并由读路径过滤；普通 list/banner 不得显示不可见消息。Invalidation 只发送到消息所属 target scope。
- Rewind suffix tombstone：suffix message 被标记为 `rewound` 前后必须清理或过滤相关 `scope_message_pins`、`scope_message_top_notices`，并向消息所属 target scope 广播 invalidation，让打开中的 target 刷新 list/top notice/message row state。
- Search：Pin/top notice 不改变 search document；搜索结果可展示“pinned” marker 作为当前 viewer 的附属状态，但不能让 sidecar row 成为 message 可见性的 authority。

## 5. API 规范

API 名称可以按当前 router profile 调整，但必须保持以下资源边界：Pins list 和 top notice 都是 scope/target-level resource；Pin 与 top notice 返回的 `scopeId` 都表达请求 target scope。Shared mutation 的目标 message 必须属于请求 target；personal hide/show 只作用于当前 viewer 的 top notice banner state。

### 5.1 数据结构

`MessagePin` 只表达 Pins tab/list item：

```json
{
  "id": "pin_...",
  "workspaceId": "w_...",
  "scopeId": "scp_...",
  "messageId": "msg_...",
  "pinnedByMemberId": "mem_...",
  "pinnedBy": { "id": "mem_...", "displayName": "Alice" },
  "pinnedAt": "2026-06-16T06:00:00Z",
  "message": { "id": "msg_...", "textPreview": "..." },
  "viewerCapabilities": {
    "canJump": true,
    "canUnpinForEveryone": true
  },
  "viewerState": {}
}
```

`MessageTopNotice` 只表达顶部 banner item：

```json
{
  "id": "topnotice_...",
  "workspaceId": "w_...",
  "scopeId": "scp_...",
  "messageId": "msg_...",
  "setByMemberId": "mem_...",
  "setBy": { "id": "mem_...", "displayName": "Alice" },
  "setAt": "2026-06-16T06:00:00Z",
  "message": { "id": "msg_...", "textPreview": "..." },
  "viewerCapabilities": {
    "canJump": true,
    "canRemoveForEveryone": true,
    "canHideFromBannerForMe": true,
    "canShowInBannerAgain": false
  },
  "viewerState": {
    "hiddenFromBannerForMe": false
  }
}
```

要求：

- `message` 使用当前 message read DTO 的安全子集或完整 row DTO；不得从 sidecar row 复制旧正文。
- Remove capabilities 来自服务端 target 权限判断，不来自 `pinnedByMemberId == viewer` 或 `setByMemberId == viewer`。
- Pin 的 `viewerState` 不包含 banner hide；banner hide 属于 `MessageTopNotice.viewerState`。
- 对不可见 message，整个 `MessagePin` / `MessageTopNotice` 不返回，也不返回部分字段 tombstone。

### 5.2 List pins

```
GET /api/v1/workspaces/:workspaceId/scopes/:scopeId/pins?limit=50&cursor=...
```

行为：

- 校验 actor 可读 target；
- 读取该 scope active pins，按 `(pinned_at DESC, id DESC)` 分页；
- 对每条 pin join 当前 message read model，并过滤不可见/deleted/recalled rows；
- 返回值中的 `pins[0]` 是当前 viewer 可见的 shared 最新 pin；
- `limit` 应有服务端上限，建议默认 50、最大 100；
- cursor 必须稳定表达排序位置，不能依赖客户端本地时间。

响应：

```json
{
  "scopeId": "scp_...",
  "pins": [],
  "latestPin": null,
  "nextCursor": null,
  "pinRevision": "opaque-shared-revision",
  "viewerPinStateRevision": "opaque-viewer-revision"
}
```

### 5.3 Pin message / Remove from Pins

```
PUT /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/pin
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/pin
```

Pin 行为：

1. 校验 actor 可读 target；
2. 校验 actor 可读目标 message；
3. 校验目标 message 属于该 `scopeId`；
4. 校验 actor 对 target 有 pin 写权限；
5. 在事务中插入 active pin row；
6. 命中 `(scope_id, message_id)` 唯一约束时返回既有 pin，`changed = false`，不更新 `pinned_at`；
7. 事务提交后仅在 `changed = true` 时广播 realtime pin changed。

Remove 行为：

1. 校验 actor 可读 target；
2. 校验 actor 对 target 有 pin 写权限；
3. 在 actor 可见性 predicate 下查找该 target 内的 active pin 和目标 message；
4. 若找到当前 actor 可见的 active pin，在事务中删除该 active pin row，返回 `changed = true`；
5. 若不存在当前 actor 可见的 active pin，返回成功且 `changed = false`，不泄漏 hidden/deleted message 或 hidden pin 是否存在；
6. 系统 cleanup、delete/recall/rewind 可以使用系统权限删除不可见或已删除 message 的 active pin；
7. 事务提交后仅在 `changed = true` 时广播 realtime pin changed。

响应返回当前 Pins list 或足以让客户端 invalidate/refetch 的 revision。客户端不得把 top notice hide/remove 接到这些 API。

### 5.4 Get/set/remove top notice

```
GET /api/v1/workspaces/:workspaceId/scopes/:scopeId/top-notice
PUT /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/top-notice
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/top-notice
```

Set 行为：

1. 校验 actor 可读 target/message；
2. 校验 actor 对 target 有 top notice 写权限；
3. 若同一 message 已是 active top notice，返回 `changed = false`，不更新 `set_at`；
4. 否则替换同 scope 旧 top notice，写入新的 singleton row；
5. 返回当前 `MessageTopNoticeResponse`，并在 `changed = true` 时广播 top notice changed。

Remove 行为：

1. 校验 actor 可读 target/message；
2. 校验 actor 对 target 有 top notice 写权限；
3. 删除匹配 `(scope_id, message_id)` 的 active top notice；
4. 若不存在当前 actor 可见的 active top notice，返回成功且 `changed = false`；
5. 返回当前 `MessageTopNoticeResponse`，并在 `changed = true` 时广播 top notice changed。

### 5.5 Hide/show top notice banner for me

```
PUT /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/top-notice/banner-hide?topNoticeId=topnotice_...
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/top-notice/banner-hide?topNoticeId=topnotice_...
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/top-notice-hides
```

行为：

- 第一种形式隐藏当前 actor 对指定 active top notice instance 的 banner 展示；
- 第二种形式清除当前 actor 对指定 active top notice instance 的 hide state；
- 第三种形式清除当前 actor 在该 target 下的所有 top notice banner hide state；
- 三者都必须幂等，且不修改 shared top notice row；
- 如果指定 top notice instance 已经 removed 或不可见，返回成功 no-op，不泄漏不可见 top notice 细节；
- 响应返回重新计算后的 `MessageTopNoticeResponse` 与 `viewerTopNoticeRevision`。

### 5.6 Row state in message reads

Message history、sync 或 row fetch 可以附带当前 viewer 可见的 row-level pin state：

```json
{
  "id": "msg_...",
  "pinState": {
    "isPinned": true,
    "pinId": "pin_...",
    "pinnedAt": "2026-06-16T06:00:00Z",
    "pinnedByMemberId": "mem_..."
  }
}
```

要求：

- row marker 只表示该 message 在当前 message 所属 scope 中被 shared Pin；
- parent channel 中展示 thread root message 时，不因为 thread 内 Pin 而显示 parent row marker；
- top notice 不应自动显示 Pin row marker，除非该 message 同时在 Pins list 中；
- 如果 message read path 不附带 pin state，UI 可以在打开 target 后通过 list endpoint 和局部缓存补齐 marker，但不能把 marker 缺失解释为服务端无 Pin。

## 6. Realtime 与同步

### 6.1 事件类型

Shared mutation 提交后，服务端应向订阅该 target scope 的客户端广播 realtime 事件。实现事件名应与 IM stream 的 dotted naming 保持一致：

- `message.pin.changed`
- `message.top_notice.changed`
- `message.top_notice.banner_state_changed`
- `message.pins.invalidated`

事件语义：

- `message.pin.changed` 可以被客户端用于 patch row pin state 并 invalidate Pins list；`pinned = true` 表示 shared Pin，`pinned = false` 表示 Remove from Pins；
- `message.top_notice.changed` 是 scope-wide shared event，只携带 shared invalidation 字段（scope/message/topNotice/revision），客户端必须 invalidate/refetch top notice；不得携带 actor/viewer-local `state`、`topNotice.viewerState` 或 `viewerCapabilities`；
- `message.top_notice.banner_state_changed` 是 actor/member-scoped event，只同步当前 viewer 的 personal banner hide/show/reset；
- `message.pins.invalidated` 用于 delete/recall/rewind cleanup；客户端必须 invalidate Pins list、top notice state，以及当前可见 message page 的 row state；
- 客户端必须把事件视为可修复的 invalidation，不能把 realtime stream 当作唯一 source of truth；
- no-op shared mutation 不发送 changed 事件；
- Pin/top/hide 不写 `scope_fanout_outbox` 或 `scope_activity_events`。

### 6.2 初始化与重连

Durable recovery 是 HTTP endpoint：

- 打开 channel/thread/DM/service-account conversation 时，UI 必须获取 top notice state，或在 first paint 后用 top notice endpoint 初始化 banner；
- 打开 Pins tab/list 时，调用 Pins list endpoint 分页读取完整 shared 列表；
- WebSocket 重连、workspace resume、scope switch back、检测到 shared revision gap，或检测到 actor-scoped viewer revision gap 时，客户端必须 refetch 相关 endpoint；
- 客户端可以缓存 Pins list 和 top notice state，但缓存必须以 `scopeId` 和 viewer 分区，不能把 thread 状态混入 parent channel，也不能把一个用户的 hidden state 应用给另一个用户。

## 7. UI 规范

### 7.1 消息行入口

桌面端：

- `Add to Pins` / `Remove from Pins` 进入 message row overflow menu；
- `Set as top notice` / `Remove top notice` 也是独立 action，文案必须与 Pin 分开；
- 无 shared 权限时不显示该 menu item，或显示 disabled 并给出简短原因；优先省略不可用动作以降低噪音；
- personal top notice hide 不应伪装成 row 的 unpin；
- 操作成功后 row marker、top notice、Pins list 同步更新。

移动端：

- Pin / top notice actions 作为 long-press action sheet 中的 message action；
- 排序应低于高频 reply/copy，但高于 diagnostics 类低频开发动作；
- 不把 Pin/top actions 做成气泡上的 inline icon rail。

### 7.2 Row pin marker

被 Pin 的消息行应显示轻量 pin marker，用于让用户在滚动历史中识别 pinned message。

要求：

- marker 属于 message metadata/chrome，不进入 markdown body；
- marker 只在当前 target 的 active visible Pin 中显示；
- marker 不因为 top notice 自动出现；
- marker 不改变消息作者、正文、reaction 或 thread summary 语义；
- marker 可作为打开 Pins tab/list 或提示 pin metadata 的 affordance，但不能成为唯一 remove 入口。

### 7.3 Top notice banner

每个 channel/thread/DM/service-account conversation view 可以在聊天区顶部展示一个 top notice banner。

展示规则：

- 当 target 有一条当前 viewer 可见、且未被该 viewer hidden 的 active top notice 时显示；
- 展示 message author、正文摘要或附件摘要、`set_by`、`set_at`；
- 需要明确这是当前 target 的 top notice，不暗示来自 parent/child target；
- 长消息应单行或少量行 clamp，提供 jump 查看完整上下文；
- message 被编辑后，摘要显示最新内容；
- message 被删除、撤回或不可见后，banner 自动隐藏；
- 如果 viewer 对当前 top notice 执行 hide for me，banner 对该 viewer 折叠/隐藏，但 shared top notice 不变。

操作：

- `Jump`：滚动/导航到该 message 所在位置；必要时加载历史页；
- `Hide for me`：无需 shared remove 权限，只隐藏当前 top notice instance 的 banner 展示；
- `Remove top notice`：仅在 viewer 有 shared top notice 写权限时显示；建议放在 overflow/destructive 分组。

禁止：

- 把个人 `Hide for me` 标成 `Unpin`，或把 shared remove 标成个人 dismiss；
- 因 Pin/top/hide 在消息时间线插入系统提示；
- 在 parent channel banner 中展示 child thread top notice。

### 7.4 Pins tab/list

Pins list 是独立会话 tab/panel，不是中心 modal 管理表单。

要求：

- channel 视图中入口与 `Chat / Tasks` 同级，显示 `Pins` tab；DM/thread/service-account conversation 可使用同语义的 panel/drawer 入口；
- 按 `pinned_at DESC, id DESC` 展示所有当前 viewer 可见 pins；
- 每项应像 message row：作者、最新 message preview、附件摘要、`pinned_by`、`pinned_at`；整行点击 jump；
- destructive `Remove from Pins` 收进 hover/overflow 菜单，不常驻暴露在每个 item 底部；
- 支持分页或无限滚动；
- 空状态说明当前 target 暂无 Pins；
- 当 realtime invalidation 到达时，列表应 patch 或提示刷新，不保留已删除/不可见项。

Panel/drawer 的 scope 必须固定为打开时的 target；用户切换 channel/thread/DM/service-account conversation 后，旧列表不能继续作用于新 target。

### 7.5 Jump 行为

`Jump` 必须把用户带到 message 在当前 target 的历史位置。

规则：

- 如果 message 已在当前已加载窗口内，直接滚动并高亮；
- 如果不在窗口内，使用 message around/context endpoint 或历史分页加载到该 message；
- 如果服务端返回 not found/invisible，客户端刷新 Pins/top notice state，并提示该 message 已不可用；
- reply thread 中的 Pin/top notice jump 留在 thread view，不跳回 parent channel。

## 8. 边界情况

### 8.1 幂等与并发

- 两个 actor 同时 Pin 同一 message：只有一个 active row；另一个返回 existing pin，`changed = false`。
- 同一 actor 重复点击 Pin：不改变 `pinned_at`，不重复 realtime。
- Remove 一个不存在的 Pin/top notice：返回成功，`changed = false`，不广播 removed。
- Pin/top 与 message delete/recall 并发：delete/recall 的可见性结果优先；最终 list/banner 不展示不可见 message。
- Hide top notice for me 重复点击同一 instance：只创建或保留一条 personal hide row，`changed = false` 时不刷新 shared revision。
- Hide 与 shared remove 并发：shared remove 移除 top notice；personal hide row 可保留为 stale 记录但读路径忽略。
- Show top notice again 或 reset hidden items 对不存在/不可见/stale hide state 是 no-op。

### 8.2 多 target 与跨 scope

- 同一 message 只能在它所属 target scope 内被 Pin/top；channel、DM 与 service-account sidecar state 归属各自 target，parent channel message 由 parent target 管理，child thread message 由 child target 管理。
- 同一逻辑内容在多个 scope 的 share/projection 中，各 scope 的 sidecar state 独立。
- Thread root message 在 parent channel 被 Pin/top，不表示该 thread 内有 Pin/top；thread 内 message 被 Pin/top，也不影响 parent channel。

### 8.3 成员变更

- Pinner/setter 离开 target 或 workspace 后，sidecar row 仍保留；展示降级。
- Viewer 失去 target 权限后，不能再读取 Pins/top notice 或接收该 target realtime。
- Viewer 失去某条 message 的可见性后，该 Pin/top notice 从该 viewer 的 list/banner 过滤，但对其他仍可见成员可继续存在。
- Viewer 的 top notice hide state 随 member/viewer 维度保存；viewer 失去 target 权限后不得读取或修改该 target 的 hidden state，重新获得权限时只有仍匹配当前 active top notice instance 的 hide row 才继续生效。

### 8.4 Archived/cold history

写路径只支持 live target messages。若消息已经进入 archive/cold storage：

- server 可以拒绝操作 archived message，直到 archive read/write policy 明确；
- 已存在 sidecar row 的 message 后续归档时，Pins list/top notice 是否可显示取决于 message read model 是否仍能安全加载 preview；
- 若无法加载，读路径过滤该 sidecar row，不展示损坏 preview。

## 9. 测试计划

### 9.1 Store / API

- Pin 创建：member 对可见 message Pin 成功，写入 `scope_message_pins`，返回 pin DTO。
- Pin 幂等：重复 Pin 同一 message 返回 existing pin，`pinned_at` 不变，不产生第二条 row。
- Remove from Pins：非原 pinner 但有 target 写权限的 member 可以 remove。
- Top notice 创建/替换：member 对可见 message 设置 top notice 成功；同 scope 旧 top notice 被替换。
- Top notice 幂等：重复设置同一 message 返回 existing top notice，`set_at` 不变。
- Remove top notice：非原 setter 但有 target 写权限的 member 可以 remove。
- Hide top notice for me：可读 target/message 但无 shared remove 权限的 viewer 可以隐藏 banner，`scope_message_top_notices` 不变。
- Show/reset top notice hides：清除当前 viewer hidden state 后 banner 重新出现。
- 权限拒绝：不可读 target、不可读 message、非 target member、无 write 权限均不能 shared Pin/top/remove；不可读 target/message 也不能 personal hide/show。
- Scope 校验：channel、thread、DM 与 service-account typed target 只操作自身 ReadScope 中的 message，并保持 workspace 一致。
- Workspace-owned service-account shared mutation：management_kind=workspace 的 active manager 可 Pin、unpin、设置或移除 top notice；platform-owned service-account audience members 使用读取与 personal conversation pin surface。
- 排序：Pins list 按 `pinned_at DESC, id DESC`，分页 cursor 稳定。
- 可见性过滤：deleted/recalled/rewound/invisible message 不出现在 Pins list/top notice。
- Edit 后展示：message 编辑后 Pins list/top notice 返回最新 content preview。
- 成员离开：`pinned_by` / `set_by` profile 缺失时 DTO 降级，不影响 remove 授权。

### 9.2 Realtime / sync

- `changed = true` 的 shared Pin/remove/top/untop 广播相应 realtime 事件；no-op 不广播。
- personal hide/show 不广播给其他 target 成员；同一 actor 多端通过 actor-scoped event 或下一次 refetch 收敛。
- 打开 channel/thread/DM/service-account target 初始化时能通过 endpoints 恢复 top notice 和 row markers。
- WebSocket 断线期间 mutation 后，重连 refetch 得到正确状态。
- delete/recall/rewind tombstone 或清理 sidecar rows 后，打开中的客户端收到 invalidation 并刷新。
- 多端并发 mutation 后，最终 UI 与 endpoints 一致。

### 9.3 UI

- 桌面 row overflow 正确显示 Pin/top/remove，并遵守权限 capability。
- 移动 long-press action sheet 提供 Pin/top/remove，且不引入 inline action rail。
- Row marker 只在当前 target active visible Pin 上显示。
- Top notice banner 显示 active visible top notice，并提供 Jump、Hide for me、有权限时 Remove top notice。
- Hide for me 后 banner 折叠；新 top notice 会重新出现。
- Pins tab/panel 排序、分页、空状态、jump、overflow Remove from Pins 均正确。
- Thread sidecar state 不出现在 parent channel list/banner；parent channel state 不出现在 thread list/banner。
- Service-account Pins list、top notice、row marker 与 message_scope_id 使用同一个 service-account target resolver。
- 删除/不可见 message 后，Pins/top notice 刷新或隐藏。
- Jump 能加载历史位置；message 不可用时刷新 state 并显示降级提示。

### 9.4 回归边界

- Pin/top/hide 不新增 `scope_messages` rows，不推进 unread/read cursor。
- Pin/top/hide 不触发 agent wake、daemon delivery、message freshness gate 或 search reindex。
- Personal hide 不修改 shared top notice row、message body、其他用户视图或 Pins list。
- Message history/search 仍以 live `scope_messages` 可见性为 authority，不能因 sidecar row 复活 deleted message。
- i18n：新增 UI 文案必须覆盖所有当前支持语言。
