# 前端会话缓存（统一 store + SWR + 精确权限）Spec

> Canonical path: `specs/web/conversation-cache.md`
> Status: active

## 1. 目标与根因

### 1.1 目标

打开已加载会话立即看到当前 server/viewer/epoch/workspace 分区内、此前已接受的缓存（cache-first + 后台刷新 SWR）；Event stream 增量更新；权限检查不制造第二个 loading 阶段；明确撤权后精确 fail-closed / purge、不误伤；跨 reload 有持久冷层。

### 1.2 根因

会话首屏曾同时依赖 snapshot、access、query、composer 与 scroll readiness，任何状态先后变化都可能把已可展示内容重新归约成 loading/empty。当前架构用「单一消息 store + 单一 timeline presentation resource + 独立 access registry + 独立 scroll/composer 状态」消除交叉状态乘积。

### 1.3 协议边界（含诚实限制）

消息 cache 正确性由现有 snapshot/event/page 契约覆盖；WebSocket `hello`
额外携带已鉴权 viewer 的 authoritative workspace role，用于区分 clean resume
与 role 变化。**已知限制**：异机 **channel delete（第三方）**只有粗
`channel_updated`，客户端只能等 authoritative channel diff 再 purge，
**网络窗口无法同 tick fail-closed**（§3、§7）。管理员移除成员由带
channelId 的 members event 精确 fail-closed。

## 2. 架构

### 2.1 单一消息真相源 `ConversationMessageState`

React Query exact `messages(workspaceId, scopeId)` 是**唯一可观察消息载体**。state：

```ts
type ConversationMessageState = {
  messages: Message[];
  coverage: "partial" | "complete" | "invalid";
  revision: number; // 内容写推进（stream/page/optimistic）
  invalidationGeneration: number; // destructive/unknown invalidation、full recovery、rewind 推进
  acceptedAccessGeneration: number; // 最后接受内容时的 access generation 快照（权威计数在 access registry）
  messageRevisions: Map<string, number>;
  messageEventSeqs: Map<string, number>;
  tombstones: Map<string, number>;
  frontier: number; // 来自 workspaceEventFrontierStore
  participationStats?;
  historyLimited;
  hasMore;
  hasNewer;
  ownerContextDismissedThroughGlobalSeq;
  parentScopeId?;
};
```

`lastAccessed` 是 **store 私有 LRU 元数据，不进 observable state**（避免每次 touch 触发消息树重渲染）。**删除 `recentMessageWindows`**。LRU/持久化只存 authoritative live tail（`hasNewer=false`）。

### 2.2 统一写入口 + generation fence

**所有 message page writer 走 store API**（snapshot/listMessages/stream/Inbox seed/optimistic send/share/reaction/pin/edit/recall/rewind/pagination/service-task projections）。**结构约束**：除 store 模块外不得 `setQueryData/setQueriesData(messages prefix)`（allowlist 结构测试）。

```ts
type RequestToken = {
  startRevision: number
  startAccessGeneration: number
  startInvalidationGeneration: number
  startWorkspaceGeneration: number
  lowerBoundEventSeq: number
}
beginBaseline(ws, scope) → RequestToken   // lowerBoundEventSeq 取自 workspaceEventFrontierStore
commitBaseline(ws, scope, page, token):
  // 拒绝条件（任一 generation 变化）：
  //   acceptedAccessGeneration / invalidationGeneration / workspaceGeneration !== token.start*
  // 不拒绝：普通 stream 推进 revision（保留 messageRevision > startRevision 的 entity/tombstone）
applyEvent / mergePage / purge / getState
```

**`workspaceGeneration` 权威来源**：store lifecycle/epoch token（desktop = server generation；browser = 当前 QueryClient + viewer + workspace store instance）；仅在 logout、server epoch、workspace store reset / full recovery 时 bump；**不借用 membership reconciliation generation**。`invalidationGeneration` 仅在 destructive/unknown invalidation、full recovery、rewind 时 bump，普通 stream 不 bump。

### 2.3 transport 唯一性

`scopeSnapshot` 的 messages **只是 transport input**：fetch 成功后经 token `commitBaseline` 写入 store；**scopeSnapshot query cache 只保留 metadata / hydration marker，不得保留可再次覆盖 store的 `Message[]`**。所有 baseline transport（scope snapshot / listMessages / Inbox seed）按 `(ws, scope)` 走 **single-flight coordinator**，response 不得绕过 store 直接成为 observable state。§6 的"仅 1 个后台请求" = 该 scope 的 message baseline transport **恰好一次**。

### 2.4 权限与内容解耦：access registry（证明与撤权，不控制首屏）

独立 access registry，每 `(ws, scope)` 存 `unknown | checking | authorized | revoked` + 权威 `accessGeneration`，**不复制消息、绝不持久化（仅内存）**。`checking` 表示当前 authority/freshness proof 在途：发送、管理等权限能力必须 fail-closed，但同一精确分区内此前已接受的 message page 继续展示。只有 definitive 403/404 或 authoritative absence 完成同 generation 的 revocation proof 后，才同步 `revoked + purge`，由 message store 的 `invalid` coverage 隐藏内容。proof-bound `publish-pending` 只约束写入，展示层继续保留该精确分区已接受的 page，直到 recovery baseline 原子替换。snapshot/messages `isInvalidated` 只触发后台刷新，不参与展示判定。授权 proof 见 §2.8。

### 2.4.1 单一 timeline presentation resource

`conversationTimelineResource` 是消息列表展示状态的唯一归约点：

```ts
type ConversationTimelineResource =
  | { kind: "loading" }
  | {
      kind: "ready";
      page: ThreadMessagePage;
      refreshing: boolean;
      olderMessages: "available" | "exhausted" | "unknown";
    }
  | { kind: "error"; error: Error };
```

- exact message page 存在且 coverage 可展示时始终为 `ready`；后台刷新和刷新失败都不替换已接受页面。
- 权威空页面也是 `ready`，不得先展示 empty、再切 skeleton 或 invisible pane。
- partial realtime tail 的 `hasMore` 只是未知默认值；`olderMessages=unknown` 时不得显示“加载更早”或自动分页。只有 complete baseline 可以发布 `available/exhausted` 历史边界。
- root、历史边界、message rows 与 agent activity 必须由同一个有序 DOM timeline 发布。reply thread 的固定顺序是 `root → 加载更早 → replies → activity`；分页入口永远不得出现在 root 上方，activity 也不得作为独立 footer 提前提交。
- conversation 首屏只允许一个 renderer：resource 的当前有序 items 直接进入普通 DOM，不得再增加 virtual-list 隐藏测量、viewport snapshot、冻结 page 或 renderer handoff。native message scroller 只在 layout effect 内执行 latest/target 定位，不持有消息数据、不参与权限与分页判断。
- 浏览器与桌面产品路由同步提交 location identity；切换 scope 时不得用 navigation transition 保留上一 scope 的已提交 DOM。URL、header 与 timeline 必须属于同一个 route identity，旧会话不能在新 URL 下等待新列表渲染完成后再被替换。
- accepted cache 首次完成定位后，exact-key authoritative baseline / newer reconciliation 只更新统一 store，不得把当前 viewport 自动替换成新的 live tail；native scroller 保持已显示的 anchor，并通过已有“新消息 / 回到底部”入口交还跳转决定。对账 settle 后，仍在底部的普通 live event 可恢复 follow-latest；用户主动发送与显式“回到底部”继续表达 latest intent。
- timeline tail 使用固定 padding 并预留折叠 agent activity 行。加载更早前必须捕获当前可见的旧消息及像素 offset；prepend 后恢复同一消息的 offset，禁止跳到新 page 第一条或因仍停留在加载阈值内而连续自动分页。
- 没有页面时保持单一 `loading`，直到首个 page 或 terminal error。
- `invalid` 不得暴露 raw query cache；`publish-pending` 仍可展示同一精确分区此前已接受的 page，但除 proof-bound recovery baseline 外的写入继续 fail-closed。恢复 baseline 完整发布时原子替换为新 page。
- 只有当前 exact-key 请求已经 settle 的 404，或 typed access proof 得出的 definitive denial，才允许 purge。正在 revalidate 的缓存 403/404、route loading/identity-completion 等中间状态均不是撤权证据。
- access registry、composer mode 与 scroll positioning 均不得覆盖此资源的 `kind`。scroll 只定位 `ready` 内容，composer 只控制编辑与发送。

### 2.5 frontier 来源 `workspaceEventFrontierStore`

由 workspace snapshot 初始化；**只在 flush controller apply+commit/ack 成功后推进**；`beginBaseline` 读取。不用组件私有 `imEventSeqRef`、不用"已收到未 apply"的 seq。下界对账：后端读点 B ≥ F，snapshot 为基线 + 保留 `startRevision` 后 entity + replay `eventSeq>F` 幂等，(F,B] 至多重复合并、不漏。

### 2.6 安全 purge + 持久化序列化

`purge` 不能直接 `removeQueries`（active observer 持旧 result）：

1. 同步 `access=revoked` + bump accessGeneration；
2. active exact query 写无内容 tombstone / empty revoked state；
3. 取消该 scope 在途请求；
4. inactive 后物理 remove；
5. 所有 baseline / IDB 回写校验 generation（旧 generation 响应拒绝复活）。
   parent purge 通过内存 + IDB parent index 级联全部已知 child。
   **持久化复活竞态**：仅在内存查 generation 不足以阻止"旧 IDB write 已启动 → purge delete 完成 → 旧 write 最后提交"。规定 **per-scope 持久化操作序列化**（同一 readwrite transaction 内 generation tombstone / CAS），保证 purge 排在旧写之后、旧 read response 仍由 token 拒绝。**scope revoke 只删 scope + children；logout / epoch 才清/隔离整个 partition。**

### 2.7 IndexedDB 冷层（固定 schema/预算/分区）

原生 IDB wrapper（无依赖）。

- **Schema**（per partition）：`messages`（key=`(workspaceId, scopeId)`，value=`{state, tail, writtenAt, generation}`）、`parentIndex`（child scopeId → parent）、`meta`（schemaVersion）。
- **预算（固定值）**：≤ 200 persisted scopes / ≤ 25 MiB / tail 30 条 / TTL 7 天；按 `writtenAt` LRU，先逐 scope 再总 bytes 淘汰；schema bump = 整 partition 丢弃重建。
- **Partition**：browser = server origin + viewer；desktop = serverId + desktop generation + viewer；**key 含 workspaceId**。
- **access registry 绝不持久化**。
- logout / epoch → 清或隔离 partition；IDB 不可用 / 超额 → 降级内存。
- 冷启仅从当前精确 partition 读取；已持久化的 accepted page hydrate 后立即展示，并精确 invalidate 当前 message key 以启动一次 authoritative baseline；access fresh proof（§2.8）只证明权限，不能替代 message freshness 或阻止这次 message reconciliation。
- 持久化序列化见 §2.6。
- **测试通过注入 storage adapter / fake，不碰真实 IDB。**

### 2.8 授权 proof table + 启动/重连迁移

fresh proof 必须 **fresh、属于当前 accessGeneration**；cached query data 不算：
| scope kind | fresh proof |
| --- | --- |
| channel | 完整非分页 `listChannels` presence 或 exact `getChannelAccess` |
| child reply thread | fresh authorized parent + stored parent relation，或 viewer-scoped `listMessages`/scope response 成功 |
| DM | exact `getDMChannel` 或完整 DM presence |
| service / task | 现有 viewer-scoped `listMessages` / scope response 成功 |
**列表 absence 只有在明确完整 authoritative list 时才能 revoke**（分页 absence ≠ revoked；只 exact 403/404 或 authoritative parent/channel diff revoke）。**不留"exact DM access / service-task access"等不存在的占位 API。**

**启动/重连迁移**（可实施规则）：首次内存打开、cold hydrate、以及可能漏掉
live-only membership 事件的 transport reconnect：同步
`unknown/authorized → checking` 并发出 channel/DM fresh proof；accepted cache
保持展示；成功 → `authorized`；网络失败保持 `checking` 并按 owner 的重试策略
恢复，不能清空 timeline；403/404/diff → `revoked` + §2.6 purge。
页面恢复可见时，只有 transport 已报告断开才 claim reconnect；空闲但仍为
open 的 WebSocket 没有 application heartbeat，连接建立时刻变旧不构成 gap
证据，也不得触发 fail-closed access proof 和可见消息清空。
`hello.viewerRole` 与本地已确认 role 相同表示 clean workspace-role resume，
不得 destructive clear/refetch Apps、Tasks、Agents 或 messages；role 改变或字段
缺失才执行 current-member reconciliation。Service Account 删除仍是 live-only，
所以 clean resume 只额外恢复其 directory/snapshot/conversation authoritative
roots 与当前 active detail/conversation leaves。普通 durable event gap 由 replay
收敛，只有 unresolved gap /
`snapshot.required` 可以成为 snapshot/full recovery owner；异步 gap snapshot
必须同时受 transport generation 与 committed frontier fence 约束。React Query
全局关闭 network reconnect 自动 refetch，避免与 socket resume 重复发布。
**同一稳定连接内 A→B→A 保留内存 `authorized`**
（满足 fresh=0 请求 / stale=SWR）。

**`channel_members_updated` 不含 target user**：当前协议只能对该 channel
当 tick `checking`。普通成员 churn 可能暂时阻止权限型操作，但不隐藏已接受的
timeline；fresh proof 明确失权后才 purge。

### 2.9 显式状态机

单 scope 的展示状态只有 `loading → ready(cache, refreshing?)` 或 `loading → error`；已有 `ready` 在刷新成功前保持稳定。权限状态独立演进：`unknown/authorized → checking → authorized|revoked`。`checking` 不改变展示；`revoked` 必须与 §2.6 purge 同一 owner 执行，purge 后 coverage 使展示离开 `ready`。invalidation reactive 仅触发后台刷新。

## 3. 现有契约覆盖矩阵

| 场景                                                                         | 现有契约                                                                            | 当前实现                                                                                                                                                     |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 消息 create/edit/recall × snapshot 并发                                      | durable stream 按 eventSeq 连续 apply；投影 write-before-publish                    | beginBaseline 记 token；commitBaseline 以 page 为基线保留 startRevision 后 entity；generation 不变即接受，(F,B] 幂等重复合并、不漏                           |
| gap / reconnect                                                              | flush controller contiguous prefix + resume(afterEventSeq) + 超时 snapshot recovery | store 接入 flush 事件；frontier 仅 apply+commit 后推进；clean reconnect 只 replay + §2.8 access proof；仅 unresolved gap / `snapshot.required` full recovery |
| 在线管理员移除成员                                                           | `channel_members_updated` 带 channelId                                              | 该 channel 当 tick `checking`，权限操作暂停但 accepted timeline 保持展示 → exact access recheck；inaccessible/404 则 §2.6 purge                              |
| 本机 join/leave                                                              | mutation 已知 channelId                                                             | 成功时精确 markAuthorized / purge                                                                                                                            |
| 普通成员/agent/service 变化                                                  | members / channel_updated                                                           | 只检查该 channel                                                                                                                                             |
| 粗 `channel_updated`（metadata/CRUD）                                        | workspace-wide 无 channelId                                                         | 只刷 channel/auth 列表；fresh 完整列表与本地授权集合 diff，§2.6 purge 真正消失的 scope                                                                       |
| 离线撤权 / 冷启动                                                            | —                                                                                   | 精确 partition 的 IndexedDB accepted page 立即展示并后台 fresh proof；明确失权后同步 purge，旧 IDB/HTTP 回写由 generation fence 拒绝                         |
| 子 thread                                                                    | parent 关联                                                                         | store 存 `parentScopeId` + IDB `parentIndex`；parent checking 不清空 child；parent purge §2.6 级联全部已知 child                                             |
| 403/404、logout、账号切换、desktop epoch                                     | —                                                                                   | purge/隔离 partition；旧 payload + 在途响应由 generation fence 拒绝复活                                                                                      |
| **限制**：异机 channel delete（第三方）/ self-leave 只有粗 `channel_updated` | 无 channelId                                                                        | channel delete 是第三方动作：等 authoritative channel diff 再 purge，**网络窗口无法同 tick fail-closed**（§7）                                               |

## 4. 模块职责

- `conversation/state/conversationMessageStore.ts`：`ConversationMessageState` + `beginBaseline/commitBaseline/applyEvent/mergePage/purge/getState` + workspaceGeneration lifecycle token + messages-write allowlist 结构测试。
- `conversation/state/accessRegistry.ts`：`unknown/checking/authorized/revoked` + 权威 `accessGeneration`；`markChecking/markAuthorized/markRevoked/isAuthorized`；**仅内存**。
- `conversation/state/conversationTimelineResource.ts`：把 exact message page、coverage 与 query status 归约为唯一的 `loading/ready/error` 展示资源；不读取 composer、route access 或 scroll state。
- `conversation/state/workspaceEventFrontierStore.ts`：init from workspace snapshot；advance on flush apply+commit/ack。
- `conversation/state/conversationMessagePersistence.ts`：原生 IDB wrapper（§2.7 schema/预算/分区 + §2.6 per-scope 序列化；注入 adapter）。
- `conversation/state/baselineTransportCoordinator.ts`：per-(ws,scope) single-flight for scope snapshot / listMessages / Inbox seed。
- 全部 message writer 通过 store API；scopeSnapshot 只承担 transport input。
- Inbox conversation index 只提供 freshness projection，不成为第二个 message writer：当其 `lastMessageAt` 明确晚于某 scope 已接受的 authoritative live tail 时，精确 `markInvalidated(scopeId)` 并触发该 exact message baseline；不得用 preview 合成消息，也不得用该信号覆盖 `hasNewer=true` 的显式历史窗口。
- `recentMessageWindows.ts` 与 `useIsScopeSnapshotInvalidated` 不再存在，
  不能作为 compatibility cache 或展示栅栏重新引入。

## 5. 变更约束

- 新增 message writer 必须经过 `conversationMessageStore`，并由 writer
  allowlist 结构测试锁定。
- reconnect、snapshot、pagination 和 stream writer 的变更必须覆盖
  generation/frontier/access 竞态，不能只验证最终数组内容。
- Web 变更至少运行对应定向测试、typecheck、workspace boundary guardrail
  与 `make harness-fast`；失败时不能通过放宽 allowlist 或提高基线绕过。

## 6. 测试锁定（可验收断言）

- fresh A→B→A：**0 网络请求**（内存 authorized 命中）。
- stale A→B→A：立即显示 + 该 scope message baseline transport **恰好 1 次**。
- accepted cache 已定位后 baseline 补入 newer tail：原 viewport anchor 不变，显示新消息入口；对账无内容变化时恢复正常 follow-latest。
- Inbox conversation index 的 `lastMessageAt` 领先 accepted live tail：只精确失效该 scope 并补一次 baseline；相等时间、invalid/publish-pending coverage、空 cache 与 `hasNewer=true` 历史窗口均不据此推断缺口。
- access `checking`：已接受 cache 保持可见；权限型操作保持不可用；不得出现第二个 skeleton/empty phase。
- 无 cache：从 mount 到首个 page 只有一个连续 skeleton phase；snapshot 在 authorization publication 之前写入时立即成为 `ready`。
- 网络失败：已有 cache 保持可见；无 cache 才进入 terminal error。
- 403 purge 后：旧 HTTP + IDB 响应都**不能复活**（generation fence + per-scope 持久化序列化）。
- cold hydrate：精确 server/viewer/epoch/workspace partition 的 accepted page 在 fresh proof 期间可见，并在 timeline 可请求时恰好执行一次 message baseline 修复陈旧/缺口 cache；错误 viewer 或 epoch partition render **0 条内容**。
- 权威空 page：直接稳定为 empty ready state，不得经过 invisible blank pane。
- reply thread：partial tail 在 complete baseline 前不显示“加载更早”；complete baseline 有更早历史时顺序固定为 `root → 加载更早 → replies → activity`，`hasMore=false` 时无分页控件。
- snapshot × create/edit/recall 并发：无丢、无重、tombstone 正确。
- gap resume / full recovery；重复事件幂等。
- same-role hello：Apps/Tasks/Agents/messages **0 主动 refetch**；role mismatch / legacy hello 才触发精确 membership reconciliation。
- online + visibility 同轮恢复：单一 reconnect claim；hidden 时暂停，失败使用有上限的指数 backoff。
- parent-child purge（内存 + IDB parent index 级联）。
- 在线 / 离线撤权；在途响应 fence。
- IDB schema / partition(workspaceId in key) / LRU(200/25MiB/30/7d) / logout（注入 adapter）。
- messages-write allowlist（结构测试）。

## 7. 不变式与边界

- 冷启/重连的当前权限真相永远以服务器 fresh proof（§2.8）为准；持久 cache 只代表该精确 partition 曾接受的内容，可用于即时展示，但不能自行开放发送、管理等权限能力。
- access `checking` 永远不是 timeline 的空值；只有 message store 的显式 purge/recovery coverage 可以撤下已接受内容。
- composer capability、timeline presentation、scroll positioning 与 route transport readiness 是四个独立状态域；不得用其中一个域的 pending/ready 布尔值推导另一个域。
- access registry **绝不持久化**；权威 accessGeneration 只在 registry（message state 仅存 acceptedAccessGeneration 快照）。
- transport replay 是 reconnect 数据恢复唯一 owner；React Query reconnect
  与 clean `onOpen` 不得再次全量刷新 active queries。
- 投影写入 happens-before 事件 publish（后端既有契约，依赖不改）。
- 已知限制：异机 channel delete（第三方）网络窗口无法同 tick fail-closed；definitive proof 到达前，精确 partition 内此前已接受的 cache 仍可能短暂可见。

## 8. 业界依据

- **TanStack Query**：cache 立即返回 + 后台 refetch；`staleTime` vs `gcTime`。https://tanstack.com/query/latest/docs/framework/react/guides/caching
- **SWR**：immediacy + freshness 并存。https://web.dev/articles/stale-while-revalidate
- **Discord Gateway**：单调 seq + RESUME/IDENTIFY replay。https://docs.discord.com/developers/events/gateway
- **IndexedDB**：结构化客户端存储 + 事务；持久 cache 需 schema/buster/TTL。https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
