# Workspace IM Snapshot / Delta / Event Stream Protocol

> Canonical path: `specs/im/workspace-im-sync-protocol.md`
> Status: target design
> Related: `specs/message/scope-model.md`, `specs/message/read-path-performance-boundaries.md`, `specs/message/workspace-global-seq-cursor-design.md`, `specs/message/delivery-runtime.md`

本文定义 AgentTeam IM 的绿色目标态通信模型：由服务端拥有每个用户在 workspace 内的事件日志和投递 checkpoint；客户端通过首屏 snapshot + 单一 delta/event stream 协议获得实时增量，并用 ack checkpoint 明确确认已经应用到本地缓存的进度。HTTP 只承担首屏、分页和幂等 mutation；WebSocket 是默认且唯一的实时通道，承担增量、恢复、订阅可见 scope 和 ack。

## 1. 目标和非目标

### 1.1 目标

- 给 web / app / daemon 提供统一 IM 同步模型，避免每个页面分别轮询 messages、scopes、conversations、unread、reaction 等接口。
- 使用 `snapshot -> event stream -> ack checkpoint -> resume` 的协议闭环，保证客户端断线、刷新、切换设备后可以从服务端 checkpoint 恢复。
- 服务端拥有 per-member workspace event stream。客户端只能确认“已应用到本地”的位置，不能伪造投递事实。
- 保留 scope-local message sequence 作为消息事实顺序；event log 只表达“当前用户在当前 workspace 下需要看到的变化”。
- API 统一挂在 `/api/v1/workspaces/:workspaceId/...` 前缀下，减少全局接口和 workspace 路由混用。
- 新代码在 `agentteam-server` 内按 workspace IM 模块组织，路由、handler、service、event stream 和 storage 边界清晰。
- Spec 只描述目标态：可以删除或替换旧 API；旧 API 兼容层、SSE fallback 和非 WebSocket 实时降级都不在设计范围内。

### 1.2 非目标

- 不把 IM event log 设计成全站 Kafka 或跨 workspace 总线。
- 不要求 workspace 内所有消息重新拥有单一严格全序；scope 内顺序仍由 `scope_messages.seq` 保证。
- 不用 WebSocket 承载历史消息无限分页；历史分页仍走 HTTP。
- 不把 unread 精确数字作为 workspace 层产品承诺；workspace 层优先是 `hasUnread` 红点语义。
- 不把 agent runtime delivery ledger、daemon local queue 或 MCP tool 队列当成人类 IM 同步事实。
- 不设计旧全局 IM / polling API 的兼容层；实现时可移除、替换或封存旧接口，但本 spec 不定义兼容行为。
- 不提供 SSE fallback、HTTP long-poll fallback 或 `events/resume` 短轮询通道；实时能力仅通过 WebSocket 暴露。

## 2. 核心模型

### 2.1 四个协议面

| 面 | 作用 | 传输 | 典型接口 |
| --- | --- | --- | --- |
| Snapshot | 首屏和冷启动基线，返回当前可见 scopes、conversation 列表、最近消息、read/unread 概览和可恢复 cursor | HTTP | `GET /api/v1/workspaces/:workspaceId/snapshot` |
| Delta / Event Stream | 服务端持续推送当前用户可见的变化，包含 message、reaction、scope、conversation、presence、read-state 等事件；客户端通过同一连接发送 ack、resume、subscribeVisibleScope 等控制事件 | WebSocket only | `WS /api/v1/workspaces/:workspaceId/events/stream` |
| Ack Checkpoint | 客户端确认事件已经应用并持久化到本地缓存，服务端据此推进该 client 的 checkpoint 并 GC 旧事件 | WebSocket only | `ack` frame |
| History Pagination | 显式读取某个 scope / conversation 的历史页，不推进 live event checkpoint | HTTP | `GET /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages` |

### 2.2 Server-owned per-member workspace event stream

目标状态下，服务端为每个 `(workspace_id, member_id)` 维护可恢复的 event stream。每条事件代表“这个用户在这个 workspace 下应该感知的状态变化”，而不是消息事实本身。

概念字段：

```text
im_stream_events(
  workspace_id,
  member_id,
  event_seq,
  event_id,
  type,
  scope_id,
  conversation_id,
  entity_id,
  entity_seq,
  payload_json,
  created_at,
  expires_at
)
```

约束：

- `event_seq` 只在 `(workspace_id, member_id)` 内单调递增，用于恢复和 ack；它不替代 `scope_messages.seq`。
- `event_id` 必须可去重；客户端重复收到同一事件时必须幂等应用。
- `entity_seq` 指向事实表的局部序列，例如 `scope_messages.seq` 或 reaction revision。
- payload 可以是完整 projection，也可以是引用 + minimal patch；具体选择由事件类型和响应大小决定。
- event log 可以设置 TTL，但不得短到覆盖常见离线窗口；过期后客户端必须能通过 snapshot 重新建基线。

实现可以从现有事实表和 dirty scope / updated_at 索引实时生成 delta，也可以直接落 `im_stream_events` 表；无论内部实现如何，对外都必须表现为有序、可 ack、可 resume 的 WebSocket event stream。

### 2.3 Snapshot / Delta 单通道协议

客户端启动流程：

1. `GET /api/v1/workspaces/:workspaceId/snapshot?clientId=...` 获取首屏基线和 `streamCursor`。
2. 客户端渲染 snapshot，并持久化 snapshot 中的 cursor generation。
3. 客户端打开 `WS /api/v1/workspaces/:workspaceId/events/stream?clientId=...&cursor=...`。
4. WebSocket 先补发 cursor 之后的事件，再进入 live push。
5. 客户端按顺序应用事件；每应用并持久化一段事件后发送 ack。
6. 断线时客户端用最后已 ack 或本地已持久化的 cursor resume；如果 cursor 过期，重新 snapshot。

单通道含义：同一个 client 在同一个 workspace 下只应有一个主 IM event stream。不同功能模块不要各自建立消息、reaction、unread、scope 的并行 realtime 通道。服务端可以在同一 stream 内复用 topic / type 做分发。

### 2.4 Ack checkpoint

Checkpoint 以 client 为粒度，避免同一用户多设备互相覆盖进度：

```text
im_stream_cursors(
  workspace_id,
  member_id,
  client_id,
  acked_event_seq,
  acked_event_id,
  cursor_generation,
  updated_at
)
```

语义：

- ack 表示“客户端已成功应用并持久化事件”，不是“用户已读消息”。read state 仍由 scope-local read API 管理。
- ack 必须单调推进；服务端拒绝回退 checkpoint。
- ack 可以批量，例如每 N 条事件或每 T 秒发送一次；重要事件可以要求更快 ack。
- WebSocket 中的 ack frame 是唯一 ack 路径；HTTP API 不提供 ack 兼容端点。
- 服务端可以在 checkpoint 覆盖事件后 GC 对应 event log，但要保留足够窗口处理重复 ack、重连和乱序网络。

### 2.5 Event 类型

建议最小事件族：

| Type | 含义 | 幂等键 |
| --- | --- | --- |
| `message.created` | scope 中新增消息 | `messageId` |
| `message.updated` | 消息编辑、撤回、系统状态变化 | `messageId + revision` |
| `message.deleted` | 消息删除或隐藏 | `messageId + revision` |
| `reaction.changed` | reaction 增删或聚合变化 | `messageId + reactionRevision` |
| `scope.upserted` | channel / DM / thread metadata、membership 或 visibility 变化 | `scopeId + revision` |
| `scope.removed` | 当前用户不再可见该 scope | `scopeId + revision` |
| `conversation.upserted` | conversation 列表项、last message、badge、pin/archive 等变化 | `conversationId + revision` |
| `read_state.changed` | 当前用户或需要展示的对端 read state 变化 | `scopeId + memberId + readSeq` |
| `presence.changed` | 在线状态、typing、临时态 | `presenceKey + revision` |
| `snapshot.required` | 当前 cursor 已过期或无法增量恢复 | `reason + generation` |

事件 payload 必须包含足够的 workspace、scope、conversation 标识，客户端不应依赖当前路由上下文来解释事件。

## 3. API 设计

所有 IM API 使用 workspace 前缀：

```text
/api/v1/workspaces/:workspaceId/...
```

### 3.1 Snapshot

```http
GET /api/v1/workspaces/:workspaceId/snapshot?clientId=web-...&limitScopes=50&limitMessages=30
```

响应示例：

```json
{
  "workspaceId": "wsp_...",
  "memberId": "mem_...",
  "snapshotVersion": "snap_...",
  "streamCursor": "evtcur_...",
  "serverTime": "2026-06-09T00:00:00Z",
  "scopes": [{ "scopeId": "scp_...", "type": "channel", "lastSeq": 128, "hasUnread": true }],
  "conversations": [{ "conversationId": "cnv_...", "scopeId": "scp_...", "lastMessageId": "msg_..." }],
  "messages": { "scp_...": [{ "messageId": "msg_...", "seq": 128, "body": "..." }] },
  "readState": { "scp_...": { "lastReadSeq": 120 } }
}
```

规则：

- snapshot 是首屏基线，不是全量导出；scope、conversation 和 messages 都必须分页 / 限量。
- `streamCursor` 表示“snapshot 覆盖的基线之后”。WebSocket 用它补发后续事件。
- snapshot 默认不推进已有 client 的 event ack。第一方 Web、Desktop 和 miniapp
  冷启动客户端可以使用受校验的 client identity 显式请求
  `advanceStreamCursor=true`；服务端只在 snapshot 完整组装成功后把该 client 的
  checkpoint 单调推进到 `snapshotEventSeq`，后续 WebSocket 从该 fence 之后开始。客户端在
  snapshot 请求失败或响应丢失时不得退回旧 cursor 建连，必须重新获取 snapshot。
- Web 和 Desktop 的冷启动 client 使用 tab / webview 级身份：
  `workspace-im:v1:<workspaceId>:<userId>:<instanceId>`。`instanceId` 保存在
  `sessionStorage`，同一 tab 刷新后稳定、不同 tab 相互隔离；Desktop 的本地
  cursor storage key 还必须包含 server identity 和 generation，防止跨 server
  epoch 复用。
- miniapp 冷启动 client 使用安装级稳定身份并绑定 workspace：
  `miniapp:<installationId>:<workspaceId>`。`installationId` 首次安装时生成并持久化，
  不随登录、server 或 workspace 切换而变化；不同安装不得共享 durable checkpoint。
- `advanceStreamCursor=true` 仅接受与当前已认证 user / workspace 严格匹配的
  `workspace-im:v1` client，或来自 miniapp route source 的上述 miniapp client；
  普通 snapshot 读取仍可使用其他 clientId，但不得借 snapshot 推进任意 client
  的 checkpoint。
- 如果 snapshot 期间数据变化，服务端必须通过 cursor generation / event fence 保证后续 stream 可补齐变化，或返回 `snapshot.required` 让客户端重试。

### 3.2 Event Stream

```text
WS /api/v1/workspaces/:workspaceId/events/stream?clientId=web-...&cursor=evtcur_...
```

服务端事件 frame：

```json
{
  "kind": "event",
  "eventSeq": 1842,
  "eventId": "evt_...",
  "type": "message.created",
  "scopeId": "scp_...",
  "conversationId": "cnv_...",
  "entitySeq": 129,
  "payload": { "message": { "messageId": "msg_...", "seq": 129 } }
}
```

服务端控制 frame：

```json
{ "kind": "hello", "workspaceId": "wsp_...", "memberId": "mem_...", "viewerRole": "member", "resumeFrom": 1800 }
{ "kind": "ping", "serverTime": "2026-06-09T00:00:00Z" }
{ "kind": "snapshot.required", "reason": "cursor_expired" }
```

客户端控制 frame：

```json
{ "kind": "ack", "clientId": "web-...", "ackedEventSeq": 1842, "ackedEventId": "evt_...", "cursorGeneration": "gen_..." }
{ "kind": "resume", "clientId": "web-...", "afterEventSeq": 1842, "cursorGeneration": "gen_..." }
{ "kind": "subscribeVisibleScope", "scopeId": "scp_...", "mode": "active" }
{ "kind": "unsubscribeVisibleScope", "scopeId": "scp_..." }
```

规则：

- 同一连接内 `eventSeq` 单调递增。
- 客户端按 `eventId` 去重，按 `eventSeq` 更新本地 stream cursor。
- `hello.viewerRole` 是本次握手已鉴权 viewer 的 authoritative workspace
  role。相同 role 的 clean resume 不得清空或主动重拉 role-scoped App
  projection；role 改变或旧服务端缺少该字段时，客户端必须走精确的
  current-member fail-closed reconciliation。在线 role 变化仍由 membership
  event 处理，不能把 `hello` 当成唯一权限栅栏。
- 服务端必须在权限变化时发送 `scope.removed` 或等价事件，不能只停止推送后续消息。
- WebSocket 只推增量和小 projection；大段历史仍走 messages pagination。
- `ack`、`resume`、`subscribeVisibleScope` 和 `unsubscribeVisibleScope` 都是 WebSocket 双向控制事件，不另设 HTTP 或 SSE 版本。
- `subscribeVisibleScope` 表示当前 UI 正在展示或预取某个 scope，服务端可据此调整该连接的 projection detail、typing/presence 推送或小窗口补齐；它不改变成员关系和权限。
- `resume` 在连接已建立但客户端发现本地 cursor 落后时使用；如果服务端无法从该位置恢复，必须返回 `snapshot.required`。

### 3.3 Ack / Resume

WebSocket ack frame：

```json
{ "kind": "ack", "clientId": "web-...", "ackedEventSeq": 1842, "ackedEventId": "evt_..." }
```

WebSocket resume frame：

```json
{ "kind": "resume", "clientId": "web-...", "afterEventSeq": 1842, "cursorGeneration": "gen_..." }
```

规则：

- Ack 和 resume 只走 WebSocket event stream；目标态不定义 `POST /events/ack`、`GET /events/resume`、SSE 或 short-poll 兼容接口。
- 重新连接时，客户端优先在 WebSocket URL 上携带 `cursor`；连接内需要补齐时发送 `resume` frame。
- 服务端接受重复 ack，拒绝回退 ack，并在无法恢复时发送 `snapshot.required`。

### 3.4 Scopes

```http
GET /api/v1/workspaces/:workspaceId/scopes?cursor=...&limit=50&filter=joined
GET /api/v1/workspaces/:workspaceId/scopes/:scopeId
PATCH /api/v1/workspaces/:workspaceId/scopes/:scopeId
POST /api/v1/workspaces/:workspaceId/scopes
```

规则：

- scopes API 管理 scope metadata、membership、notification setting 和 visibility 状态。
- 读取列表必须分页，不允许首屏一次性扫描 workspace 全量 scope。
- scope mutation 成功后应产生 `scope.upserted` / `conversation.upserted` 事件。

### 3.5 Messages

```http
GET /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages?beforeSeq=129&limit=50
GET /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages?afterSeq=120&limit=50
POST /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages
PATCH /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId
```

规则：

- 历史分页使用 `scopeId + seq` 或 opaque history cursor，不使用 workspace-global scalar cursor。
- 发送消息的响应返回写入事实和当前 scope tail；增量通知仍通过 event stream 分发。
- message mutation 必须产生对应 event，且 event payload 至少包含 `messageId`、`scopeId`、`seq/revision`。

### 3.6 Conversations

```http
GET /api/v1/workspaces/:workspaceId/conversations?cursor=...&limit=50&kind=all
GET /api/v1/workspaces/:workspaceId/conversations/:conversationId
PATCH /api/v1/workspaces/:workspaceId/conversations/:conversationId
```

Conversation 是列表 projection，不是第二份消息事实。它可以引用 channel、DM、thread、task timeline、service-account conversation 等 scope。projection 可长期物化，以支持列表排序、pin/archive、last message preview 和 badge。

### 3.7 Threads / Reactions

```http
GET /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/thread
POST /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/reactions
DELETE /api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/reactions/:reaction
```

规则：

- reply thread 可以建模为独立 scope 或 parent message 下的 projection，但 event 必须能让客户端更新 thread badge、last reply 和 reaction 聚合。
- reaction 写入不要求重新拉取整条消息；event payload 应支持局部更新。

## 4. 数据库访问模型

### 4.1 目标态访问边界

目标态可以复用现有 `scopes`、`scope_messages`、`scope_memberships`、`scope_fanout_outbox`、reaction / search / read-state 表作为事实来源，但读写路径必须按 workspace-prefixed IM API 和 WebSocket event stream 收敛：

- 首屏 snapshot 只做 bounded query：分页 scopes / conversations，按选中 scope 拉最近 N 条消息。
- 历史读取只按 `(workspace_id, scope_id, seq)` 查询。
- 增量候选优先来自现有 updated_at、scope tail、dirty marker 或已有 realtime publish，不做 workspace 全量扫描。
- 客户端所有实时变化统一从 event stream 进入，不再由多个 HTTP poll 拼接。
- ack checkpoint 必须是服务端可恢复状态，持久化在 `im_stream_cursors`；仅内存状态不能作为目标态。

可接受的内部实现选择：

- event payload 从事实表实时 hydrate，而不是独立存完整 projection。
- conversation projection 可以先由现有 scope/message/read-state 查询组合生成。
- WebSocket 断线后如果 cursor 无法可靠恢复，可以要求重新 snapshot；但必须显式返回 `snapshot.required`，不能静默漏事件。

### 4.2 事件日志、checkpoint、projection

建议引入三类持久化：

1. `im_stream_events`：per-member workspace event stream，用于 resume、ack 后 GC、调试“为什么客户端没收到”。
2. `im_stream_cursors`：每个 client 对 member stream 的已应用 checkpoint。
3. `im_conversation_index`：member 视角下的 conversation 列表物化索引，承载 last message、sort、pin/archive、route/title 等非 unread 读模型。普通 Workspace IM unread badge/count 由 owner-local UnreadRuntime 服务，不写入该索引。

可选辅助表：

```text
workspace_event_cursors(
  workspace_id,
  member_id,
  next_event_seq,
  updated_at
)

im_conversation_index(
  workspace_id,
  member_id,
  conversation_id,
  scope_id,
  sort_at,
  last_message_id,
  last_message_seq,
  pinned_at,
  archived_at,
  payload_json,
  updated_at
)
```

### 4.3 写入事务边界

发送消息的目标事务：

1. 验证 workspace、scope、membership、visibility 和写权限。
2. 在 scope 内分配 `seq`，写 `scope_messages`。
3. 更新 scope tail、conversation projection 所需事实、read/unread 相关 state。
4. 写入或标记可见用户的 event log / dirty projection。
5. 提交后通过 realtime 层唤醒在线连接。

如果实现不能在同一事务写完整 event log，至少要有 outbox / dirty marker 保证提交后事件不会因进程崩溃而永久丢失。

### 4.4 读取成本边界

- snapshot 不得随 workspace 历史消息总量线性增长。
- event stream resume 不得扫描所有历史消息来找“未投递”。
- conversations 列表不应每次 N+1 读取 last message、unread 和 reaction；需要 projection 或批量查询。
- workspace 级 unread 只承诺 `hasUnread`，避免精确 count 写放大和读放大。
- 权限过滤必须在服务端完成；客户端收到的 event 已经是当前用户可见事件。

## 5. agentteam-server 代码组织建议

目标态的新代码放在 `agentteam-server` 内，按 workspace IM 领域组织。以下路径是建议结构，实际落地时可按现有工程命名微调，但职责边界应保持一致：

```text
agentteam-server/
  internal/
    api/
      v1/
        workspaces/
          im_routes.go            # /api/v1/workspaces/:workspaceId/... 路由注册
          im_snapshot_handler.go  # snapshot HTTP handler
          im_scopes_handler.go    # scopes HTTP handler
          im_messages_handler.go  # messages HTTP handler
          im_conversations_handler.go
          im_events_ws_handler.go # WebSocket upgrade + frame agentteam
    im/
      snapshot/                   # snapshot assembler、bounded read orchestration
      scopes/                     # scope metadata、membership、visibility service
      messages/                   # send/edit/delete/history service
      conversations/              # conversation projection service
      events/                     # event log、cursor、ack、resume、fanout contracts
      stream/                     # WebSocket connection manager and frame codecs
      storage/                    # SQL/kv repositories for IM tables/projections
      authz/                      # workspace/scope permission checks
```

### 5.1 路由分组

所有 IM 路由挂在 workspace router 下：

```text
/api/v1/workspaces/:workspaceId/snapshot
/api/v1/workspaces/:workspaceId/scopes
/api/v1/workspaces/:workspaceId/scopes/:scopeId
/api/v1/workspaces/:workspaceId/scopes/:scopeId/messages
/api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId
/api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/thread
/api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/reactions
/api/v1/workspaces/:workspaceId/scopes/:scopeId/messages/:messageId/reactions/:reaction
/api/v1/workspaces/:workspaceId/conversations
/api/v1/workspaces/:workspaceId/conversations/:conversationId
WS /api/v1/workspaces/:workspaceId/events/stream
```

目标态不注册 IM 旧 API 兼容路由；如果旧接口仍存在，应由独立清理计划删除或替换，不属于本 spec 的设计要求。

### 5.2 Handler / service 边界

- HTTP handler 只处理认证、workspace 参数解析、request validation、调用 service 和 response shaping。
- `snapshot` service 负责首屏 bounded query、cursor fence 和 response composition。
- `messages` service 负责发送、编辑、删除和历史分页；成功 mutation 必须通知 `events` service 生成对应 event。
- `scopes` service 负责 scope metadata、membership、visibility 和 notification setting；权限变化必须生成 `scope.upserted` 或 `scope.removed`。
- `conversations` service 维护 conversation projection，供 snapshot 和 conversations list 读取。
- `events` service 提供 append/hydrate/listAfter/ack/checkpoint/retention 语义，不直接持有 WebSocket 连接。
- `stream` service 管理 WebSocket lifecycle、frame codec、heartbeat、resume、subscribeVisibleScope 和 backpressure。

### 5.3 WebSocket-only realtime

`im_events_ws_handler.go` 只暴露 WebSocket upgrade：

- URL：`WS /api/v1/workspaces/:workspaceId/events/stream?clientId=...&cursor=...`
- Server -> client：`hello`（含 authoritative `viewerRole`）、`event`、`ping`、`snapshot.required`、错误控制 frame。
- Client -> server：`ack`、`resume`、`subscribeVisibleScope`、`unsubscribeVisibleScope`、`pong`。
- 不实现 SSE、HTTP long-poll、HTTP ack、HTTP resume 或旧 realtime channel bridge。

### 5.4 普通 HTTP API

以下能力保持普通 HTTP：

- `snapshot`：首屏和冷启动基线。
- `scopes`：scope 列表、详情、创建和 metadata/membership mutation。
- `messages`：历史分页、发送、编辑、删除。
- `conversations`：conversation 列表、详情和 pin/archive 等列表态 mutation。
- `thread` / `reactions`：thread projection 读取和 reaction mutation。

HTTP mutation 的响应返回本次写入结果；其他在线客户端和当前客户端的增量一致性仍由 WebSocket event stream 承担。

## 6. 约束和取舍

### 6.1 正确性约束

- 消息事实以 `scope_messages` 为准；event log 是投递 / 同步事实，不是第二份消息真相。
- ack checkpoint 不等于 read state；已收到不代表已读。
- 权限变化必须产生可见的清理事件，不能依赖客户端下次全量刷新。
- cursor 过期必须显式通知 `snapshot.required`。
- 客户端必须按 eventId 去重，服务端必须允许重复 ack。

### 6.2 性能约束

- 首屏 snapshot、conversation list、scope list、history page 都必须 bounded。
- WebSocket stream 不能把大历史页塞进实时通道。
- projection 写放大要有上限；大群公开消息不能简单 fan out 成每个成员一条重 payload 事件。可使用 sparse event、shared payload + per-member pointer、dirty marker 或 projection tail 降低成本。
- event retention 要基于 checkpoint、TTL 和容量水位；不能无限保存所有 client 的所有事件。

### 6.3 产品取舍

- Workspace 层选择 `hasUnread` 而不是精确 unread count，以换取更简单的 projection 和更低写放大。
- 不承诺跨 scope 严格消息全序；客户端展示可使用 sort hints，但同步正确性依赖 per-member event_seq 和 scope-local seq。
- 允许 `snapshot.required` 作为 cursor 过期或不可恢复时的显式重建信号；event log 应覆盖常见离线窗口。
- HTTP 和 WebSocket 分工明确：HTTP 适合分页、动作、首屏；WebSocket 适合增量、ack、恢复。

## 7. 验收标准

- 新 spec 和后续实现都使用 `/api/v1/workspaces/:workspaceId/...` 作为 IM API 前缀。
- 客户端可以通过 snapshot 完成首屏，并通过单一 event stream 接收 message、scope、conversation、reaction 和 read-state 增量。
- 客户端 ack 能单调推进 checkpoint；断线后能 resume 或收到明确的 `snapshot.required`。
- 历史消息分页走 `scopes/:scopeId/messages`，不通过 WebSocket 传大历史页。
- 数据库访问不会因为 workspace 历史消息总量或可见 scope 总量而在首屏 / resume 中无界放大。
- 实现可以复用现有消息表，但目标态必须具备 event log + checkpoint + projection 的协议能力。
