# Reaction 规范

> Canonical path: `specs/message/reaction.md`
> Related specs: [scope 消息模型](scope-model.md), [delivery-runtime.md](delivery-runtime.md), [服务号规范](../service-account/service-account.md), [rewind.md](rewind.md), [../agent/echo-agentteam-prevention.md](../agent/echo-agentteam-prevention.md), [actions.md](actions.md), [../storage/storage.md](../storage/storage.md)

Date: 2026-04-15
Status: active-spec
Scope: AgentTeam 原生 reaction 的产品语义、可见性、agent-facing activity、realtime fanout、存储模型、API 与 agent 交互规则。
Non-goals: Lark/Wechat 等外部平台的 reaction 桥接；自定义 emoji 上传与管理；emoji picker UI 细节；皮肤色调支持；service principal 的 reaction activity；独立 human activity feed / push notification subsystem。

## 1. 产品语义

Reaction 是附着在一条 scope message 上的轻量反馈，不是新的聊天消息。

AgentTeam v1 同时定义 reaction 的两个平面：

- **可见性平面**：reaction 作为消息附属状态，参与 Web/UI 展示和 history 聚合；
- **agent signal 平面**：当目标消息作者是 agent 时，添加 reaction 会作为独立 non-message activity signal 投递给该 agent。

核心决策：

- 添加 reaction 总是更新目标消息的可见状态；
- 添加 reaction 到需要 agent 处理的 message 必须进入 agent-facing activity，reactor 是目标 agent 自己时除外；
- 添加 reaction 到 human-authored message 会更新可见状态，并可向 author 的 realtime room fanout 一条 ephemeral notification；它不引入 durable unread ledger；
- 添加 reaction 到 service/app-authored message 只更新可见状态，并向已订阅该 scope 的客户端广播 realtime 更新；
- 撤回 reaction 不产生新的 activity；如果对应 reaction activity 还未被 daemon delivery 接受，则静默 suppress；
- reaction 不写入 `scope_messages`，不占用 scope `seq`，不推进普通 message cursor。

## 2. 权限与可见性

### 2.1 谁可以 react

调用方必须能通过目标 scope 的 shared SQL readable predicate / visible-row mapper 读取目标消息。

- channel scope：当前可读该 channel scope 的 member 可 react；
- DM scope：DM participant 可 react；
- reply-thread scope：当前可读该 reply-thread scope 的 member 可 react；
- service-account scope：account active 且拥有目标 scope active direct membership 的 Human 可 react；
- agent：可以对自己通过当前 agent-visible scope/source 可见的消息 react；
- private agent：只继承它已经可读的 parent/scope/source 权限，不因为 reaction 获得额外 parent scope 权限。

### 2.2 谁可以 remove

- 只能撤回自己的 reaction；
- remove 按 `(message_id, emoji, reactor_member_id)` 识别；
- remove 仍必须通过目标消息和 reaction 的 workspace/scope 边界校验。

### 2.3 哪些 reaction 会进入 activity source

- 需要进入 agent-facing daemon delivery / notify / wake 的 reaction add：写 `scope_activity_events` reaction activity；
- 不需要 durable activity 的 reaction：只更新 `scope_message_reactions` 当前状态并发送 realtime fanout；
- remove 本版不新增 `reaction_removed` activity；它只 suppress 尚未被 daemon delivery 接受的 `reaction_added` activity，并通过 realtime fanout 更新 UI。

## 3. 写入生命周期

### 3.1 添加 reaction

服务端在一个事务中执行：

1. 校验目标消息存在于 `scope_messages` 且 actor 可读；
2. 向 `scope_message_reactions` 写入当前状态行；
3. 如果命中 `(message_id, emoji, reactor_member_id)` 唯一键，视为幂等 no-op：不新建 reaction、activity、wake 或 realtime `added` 事件；
4. 如果该 reaction 需要进入 agent-facing daemon delivery / notify / wake，写入一条 `scope_activity_events`：
   - `activity_type = 'reaction_added'`
   - `activity_ref_type = 'reaction'`
   - `activity_ref_id = scope_message_reactions.id`
   - `source_message_id = target message id`
   - `actor_member_id = reactor member`
   - `reason = 'reaction'`
5. 不为 reaction fanout 写入 `scope_fanout_outbox`。当前 reaction 是
   direct post-commit reaction fanout exception：事务提交后由 API handler
   调用 `DeliveryFanout.DeliverReactionAdded` / `DeliverReactionRemoved`
   直接广播 realtime，并在需要 agent-facing signal 时依赖已提交的
   `scope_activity_events` 供 delivery diagnostics / refetch / direct fanout recovery 使用。

Reaction activity 本身不持有 recipient 列表；agent-facing delivery/fanout 的 recipient 从 source message 的 agent author 派生，并继续受 source message visibility、membership、notification policy 和 delivery suppression 约束。同 scope 的其他可见 agent 不能仅因为 membership 可见而收到这条 reaction activity。

### 3.2 撤回 reaction

服务端在一个事务中执行：

1. 校验 reaction 属于当前 actor；
2. 删除 `scope_message_reactions` 当前状态行；
3. 查找对应 reaction non-message activity event；
4. 如果该 `reaction_added` activity 尚未被 daemon delivery 接受，写 `suppressed_at`；
5. 事务提交后通过 direct post-commit reaction fanout 广播
   `reaction:removed`。

Remove 不产生新的 agent-facing activity。

### 3.3 Remove 与 unread

- remove 发生在 daemon 接受 agent-facing signal 前：对应 reaction activity 必须从 activity source 中消失，不再生成后续 agent delivery；
- remove 发生在 agent 读取后：不回滚已经发生的 agent side effect，也不补发 “reaction removed” signal；
- add 已经触发 wake、remove 又 suppress activity 时允许出现空唤醒；daemon/runtime 若已收到 wake 但没有可处理 payload，应正常结束 turn。

## 4. Daemon delivery 与 runtime

Reaction activity 属于 non-message activity source：

- server 不提供 reaction `/receive` 拉取；agent-facing reaction signal 由事务提交后的 direct fanout / daemon handoff 承载；
- reaction signal 可以没有自己的 `scope_messages` row；
- reaction activity payload 可附带 source message context；
- daemon accepted delivery 不推进 member read cursor；
- reaction activity 不推进普通 dense scope message cursor。

Runtime reliability 由 [`delivery-runtime.md`](delivery-runtime.md) 和
`guardrails/message/direct-no-outbox-baseline.md` 的 direct/no-outbox
allowlist 定义：

- busy runtime 可 defer / replay reaction signal；
- reaction 不写 reaction 专属 `scope_fanout_outbox`；direct fanout 丢失时，
  browser 通过 history/message refetch 修复，agent-facing add signal 通过
  durable `scope_activity_events` 与 daemon delivery diagnostics / refetch 修复；
- reaction signal 不强造普通 message row，也不写 scope runtime ledger。

## 5. `/history` 与展示

`/history` 是消息历史读取接口，不是 signal log。

- `/history` 返回 `scope_messages`，并附带当前 reaction 聚合；
- reaction signal 不重放为历史消息；
- search、root/source context、diagnostics preview 和 attachments 都使用同一 SQL readable predicate 判断目标 message 是否可读；
- agent history 若包含 agent-authored target message，应展示当前 reaction 聚合，而不是展示每条 reaction event。

## 6. 存储模型

### 6.1 `scope_message_reactions`

当前可见 reaction 状态。

关键字段：

- `id`
- `workspace_id`
- `scope_id`
- `message_id`
- `emoji`
- `reactor_member_id`
- `created_at`

约束：

- `message_id` 指向 `scope_messages.id`；
- `(message_id, emoji, reactor_member_id)` 唯一；
- `scope_id/message_id` 用于按消息聚合和 rewind suffix 清理；
- reaction 状态不是投递事实源；agent-facing signal 由 `scope_activity_events` 表达。

必要索引：

- `(message_id)`
- `(scope_id, message_id)`
- `(reactor_member_id, created_at)`

### 6.2 `scope_activity_events` reaction signal

Agent-facing reaction signal 使用统一 non-message activity ledger。该表记录 scope 内发生的 reaction activity，本身不表示“投给某个 recipient”；delivery/fanout 根据 source message visibility、membership、notification policy 和 delivery suppression 决定哪些 agent/human 应该收到。

约束：

- `activity_type = 'reaction_added'`；
- `activity_ref_type = 'reaction'`；
- `activity_ref_id = scope_message_reactions.id`；
- `source_message_id = reacted message id`；
- `actor_member_id = reactor member id`；
- `activity_global_seq` 提供 activity source 的 ordering/cursor；
- `suppressed_at` 仅用于 rewind tombstone 或 delete 等使 activity 无效的 scope mutation。

### 6.3 Direct reaction fanout exception

Reaction 写路径只在事务内写持久 reaction state 和必要的
`scope_activity_events`；它不写 reaction 专属 `scope_fanout_outbox`。
Runtime wake、realtime、daemon handoff 等事务后副作用使用 direct
post-commit reaction fanout exception，并由
`guardrails/message/direct-no-outbox-baseline.md` 的 direct/no-outbox
allowlist 和 `guardrails/message/direct-no-outbox.sh` 约束。

Direct fanout 的输入必须来自已经提交的 mutation result：

- `Changed` 为 true 才允许发送 add/remove fanout；
- agent-facing delivery 只来自事务内创建且未 suppressed 的 reaction activity；
- recipient、scope 和 message 的权限校验以 store mutation result 为准。

该例外没有 reaction fanout retry ledger；durable recovery 依赖 reaction state
聚合、activity fact 和客户端 refetch，不得扩展为普通 message
creation 的 direct/no-outbox 许可。

## 7. API

### 7.1 Add reaction

```
POST /api/messages/:messageId/reactions
{ "emoji": "👍" }
```

行为：

- 成功写入返回当前 message reaction 聚合；
- 同一 actor 重复 add 同一 emoji 返回成功且不重复副作用；
- actor 不可读 message 时返回权限错误。

### 7.2 Remove reaction

```
DELETE /api/messages/:messageId/reactions/:emoji
```

行为：

- 只能撤回自己的 reaction；
- remove 成功后返回当前 message reaction 聚合；
- 如果 reaction activity 尚未被 daemon delivery 接受，必须 suppress；
- 如果 reaction activity 已被 cursor 越过，不补发 remove signal。

## 8. Rewind、search 和归档

- Rewind tombstone suffix 时，清理 suffix message 的 `scope_message_reactions`，并 suppress 关联 reaction activity、outbox 和 search projection；
- Search 不索引 reaction signal；搜索结果只返回可见 `scope_messages`，并可附带当前 reaction 聚合；
- Archived/cold message 的 reaction 支持不是 v1 目标；当前 add/remove gate 要求目标 message 位于 live scope transcript。

## 9. 不变量

- Reaction 不是普通消息，不写 `scope_messages`；
- reaction current state 只在 `scope_message_reactions`；
- agent-facing reaction signal 只在 `scope_activity_events`；
- remove 不产生新的 agent-facing activity；
- suppressed reaction activity 不再触发后续 agent delivery；
- reaction activity 不推进 dense scope cursor；
- reaction 不创建 side ledger，也不复制 dense fanout；
- reaction 操作必须通过目标 message 的 shared SQL readable predicate / read-check；
- reaction fanout 是 direct/no-outbox allowlist 中的显式例外，不得推广到
  普通 message creation；
- outbox 状态只解释拥有 outbox 任务的副作用 lifecycle，不决定可见性或
  membership。
