# 消息未读 Runtime 内存模型

> Canonical path: `specs/message/unread-runtime-memory.md`
> Status: target design
> Scope: 消息未读热路径读取、进程内未读状态、runtime snapshot 恢复、realtime 未读更新、冷启动恢复。
> Related specs:
> - [`specs/message/read-path-performance-boundaries.md`](./read-path-performance-boundaries.md)
> - [`specs/message/workspace-global-seq-cursor-design.md`](./workspace-global-seq-cursor-design.md)
> - [`specs/message/delivery-ledger-optimization.md`](./delivery-ledger-optimization.md)
> - [`specs/message/unread-runtime-follow-up.md`](./unread-runtime-follow-up.md)
> - [`specs/service-account/service-account.md`](../service-account/service-account.md)
> - [`specs/deployment/agentteam-server-workspace-isolation.md`](../deployment/agentteam-server-workspace-isolation.md)
> - [`specs/api/server-profile-routing.md`](../api/server-profile-routing.md)
> - [`specs/storage/storage.md`](../storage/storage.md)

本文定义 AgentTeam message surface 的目标未读架构。Unread 是热路径运行态能力，也是 attention hint，不是需要在线强一致维护的会计账本。Warm unread 读取必须由 workspace owner 进程内存服务，而不是在每次 refresh、heartbeat 或 sidebar render 时执行数据库聚合，或查询一个数据库 projection 表。

## 1. 决策

目标数据流是：

```text
TiDB 中的 durable message / message-domain non-message activity / read / policy facts
  -> workspace owner 上的 post-transaction unread events
  -> 进程内 UnreadRuntime
  -> 从内存服务 realtime push 和 HTTP read
  -> 后台 dirty-only flush 到 runtime snapshot 用于重启恢复
```

TiDB 仍然是 message rows、message-domain non-message activity rows、task typed activity rows、membership、visibility、read cursors、notification policy、manual unread facts 和 task board/status/policy facts 的 durable source of truth。这里的 activity 特指 `scope_activity_events` 中的 reaction、typed task/share/attachment activity 等 durable message-domain facts，不包括 daemon telemetry、agent runtime activity、presence、session 或 live trajectory。TiDB 不承担 unread badge 的 warm serving path，也不承担为了在线精确 unread 而持续运行的 broad repair path。

V1 可以保留一张 scope-level unread runtime snapshot 表，用于 agentteam server 重启后把内存恢复到最近一次 dirty flush 的 best-effort hint。Snapshot 表不是 hot projection 表：普通 request、refresh、heartbeat 和 ordinary message/read/manual/policy/membership/task write path 都不能直接读写它。Snapshot 通常只由 UnreadRuntime 后台按 interval/batch 把 dirty in-memory entries 刷出；没有变更过的 entry 不重复写。唯一同步例外是 durable targeted Task v2 outbox completion 对其 frozen Human recipients 执行 bounded exact snapshot barrier。重启时可以按 active member 或 visible page 懒加载 snapshot，先从已加载 snapshot 恢复内存并立即 serve。V1 启动、refresh、open-page 和 heartbeat path 信任该 snapshot；如果普通 event crash 发生在下一次 flush 前，接受 stale snapshot 是明确 tradeoff，不通过 replay messages、activities、memberships 或 legacy task unread projections 来自动 top up unread。

本设计优先保证 hot path 不制造 TiDB 读取风暴。Runtime process 存活期间的 owner-local memory 是 warm serving truth；重启后的 snapshot value 是 best-effort hint，而不是 exact ledger。外部可以显示 snapshot hint 或在安全性无法确认时不显示 unread，不能同步查询 TiDB 来伪装成精确 ready。V1 不运行自动 count reconciliation、tail replay、bounded hydrate-to-repair、active-page recovery 或 legacy task unread projection backfill 来补齐普通 crash-window unread；durable targeted Task v2 通过 completion-time exact-recipient barrier 避免其 outbox 先完成再丢失 unread，但不提供通用修补。其他精确修补只能是显式 operator/manual repair、diagnostics 或未来 V2 设计，不能挂在普通 serving path 上。

V1 snapshot freshness 不在普通 serving path 上做自动判定或修复。Snapshot 中保留 generation/frontier 字段，主要用于可观测性、diagnostics、operator/manual repair 和未来演进；它们不能驱动 refresh、heartbeat、open-page 或 startup path 去读取 durable facts 并自动修复 unread count。

V1 不新增第二张 checkpoint/frontier 表，也不把 durable anchors 变成在线 unread projection。若 operator/manual repair 或未来 V2 需要验证 snapshot freshness，应优先复用 canonical durable facts：message append 使用 `scope_messages.seq` / `scopes.latest_seq`，durable non-message activity append 使用 `scope_activity_events.seq` / `scopes.latest_activity_seq`，read cursor、membership、notification、manual unread、visibility、rewind 使用 owning row 上的单调 generation、version 或等价 commit frontier。若现有 row 只有非单调或低精度 `updated_at`，也应在 owning durable row 上补 generation 字段，而不是新增一张 unread count projection 表。

## 2. 问题

历史 unread 路径曾把数据库操作放在 broad refresh 和 polling flow 中。已删除的 warm-path 问题包括：

- `WorkspaceHandler.List` 直接调用 `HasUnreadForUserWorkspaces` 为 workspace list 填充红点。
- socket heartbeat 连接后立即加载 unread existence，并按 heartbeat interval 重复加载。
- active workspace refresh helper 在归一化当前可见 channel 前再次 fetch exact unread counts。
- Web warm refresh 通过 `listUnreadCounts` / `/channels/unread` compatibility read 重新拉取 unread state。

当前实现已经把这些普通 Web serving path 从 exact unread compatibility read 中迁走：

- `internal/api/workspace/api_workspaces.go` 的 workspace list/update 通过 `WorkspaceUnreadReader` 读取 owner-local runtime aggregate；没有 runtime 时返回 false，而不是 fallback 到 TiDB。
- `internal/api/realtime/socket_service.go` 的 heartbeat worker 只发送 liveness payload；socket 连接时通过 `UnreadRuntime.ScopeStates` replay runtime snapshot/delta state。
- `web/src/agentteam/pages/workspace-shell/useWorkspaceOverviewData.ts` 只观察 `workspaceQueryKeys.unreadCounts` 的 query cache，`enabled: false`，不会在 warm refresh 时发起 exact unread query。
- `web/src/agentteam/workspace/shared/unreadCounts.ts` 只维护 runtime/realtime seed 后的本地 query-cache patch，不封装 compatibility endpoint。
- `guardrails/message/unread-compat-builders.sh` 保护 first paint、workspace switcher、heartbeat、route-independent bootstrap 和 warm refresh frontend paths 不重新调用 exact unread compatibility APIs 或 query options。

剩余的 database aggregation helper 只应作为 store-owned compatibility/diagnostic/operator repair authority 存在，而不能被 API、realtime、bootstrap 或 Web warm paths 重新接回：

- `internal/store/app/app_messages.go`，`UnreadCountsForUser`：exact unread counts 仍可通过数据库查询加载，但 guardrail 只允许 store-local 直接使用。
- `internal/store/app/app_messages.go`，`HasUnreadForUserWorkspaces`：workspace unread presence 仍可通过数据库查询加载，但 guardrail 只允许 store-local 直接使用。
- `internal/store/message/shared_query_builders.go`，`BuildUnreadCountsForUserScopedLocalSeqQuery`：exact count 查询会 join visible scope refs、`scope_memberships`、notification settings、`scopes` 和 `scope_messages`，再按 scope 分组。
- `internal/store/message/shared_query_builders.go`，`BuildWorkspaceHasUnreadForUserScopedQuery`：workspace red-dot presence 是一个 policy `EXISTS` 查询，覆盖 memberships、scopes、policy、DM peer filtering 和 `scope_messages`。

上述查询形态比对所有消息做无界 `COUNT(*)` 更好，但一旦重新进入 refresh、heartbeat 或 first-paint path，本质上仍然会把 unread 放回数据库热路径。历史上一轮浏览器刷新可能触发以下 unread 工作：

| 步骤 | 历史 unread 工作 | 成本驱动 |
| --- | --- | --- |
| Workspace list | 一次 `HasUnreadForUserWorkspaces` 查询 | requested workspace count 加上每个 workspace 的 visible unread candidates |
| Active workspace shell | 一次 exact `UnreadCountsForUser` 查询 | visible scope refs 加上每个 read cursor 后面的 unread message tail |
| Socket reconnect | 一次立即执行的 heartbeat unread existence 查询 | active workspace 的 visible unread candidates |
| Open socket | 重复 heartbeat unread existence 查询 | 每个 heartbeat interval 重复相同成本 |
| Message-driven refresh helpers | invalidation 后再次 fetch exact unread counts | visible scope refs 加上 unread message tail |

需要移除的 warm-path 复杂度是：

```text
O(requested workspaces with unread candidate probes)
+ O(visible scopes + unread message tail)
+ O(socket heartbeat count * unread candidate probes)
+ O(refresh invalidations * (visible scopes + unread message tail))
```

具体常数会随 route 和 cache state 变化，但架构问题不变。Refresh、reconnect 和 heartbeat 不是显式要求扫描 message state 的用户意图。一个 IM 系统不能让这些常见流程依赖重复的数据库 unread read；当前 guardrail 必须继续防止旧 compatibility path 回流。

把相同 unread 答案挪到数据库 projection 表不是充分目标。它可以减少部分 `COUNT(scope_messages)` 工作，但每次 refresh 和 heartbeat 仍然会读取数据库，并且每次 message/read/policy update 又多出一个 durable 表需要保持正确。目标是把 warm unread serving path 的数据库读取移除。

## 3. 目标

1. Warm workspace unread read 执行 0 次 unread 数据库读取。
2. Warm sidebar unread count read 执行 0 次 unread 数据库读取。
3. Socket heartbeat 只承担 liveness，不做数据库 polling。
4. Message、durable non-message activity、read-state、policy 和 membership 变化在 durable write 成功后先更新 unread memory。
5. Dirty unread memory 定期 flush 到 scope-level runtime snapshot；flush 只写 dirty entries，不因 heartbeat、refresh 或 read request 产生写入。
6. Cold start 优先从 runtime snapshot 恢复并立即 serve；V1 信任 snapshot，不自动 durable tail replay、bounded recovery、legacy task unread projection backfill 或 broad unread aggregation 来补齐普通 crash-window counts；targeted Task v2 由 completion barrier 单独保证 frozen recipients。
7. 设计保留 `scope_messages`、message-domain `scope_activity_events`、`scope_memberships`、notification settings、membership、visibility 和 manual unread facts 作为 durable truth。
8. 设计不为普通 unread 引入 per-message/per-recipient durable delivery ledger。
9. Task scope unread 必须纳入 UnreadRuntime serving state 和 workspace `hasUnread`；targeted Task Message 走 `MessageStored`，targeted typed Activity 走 `NonMessageActivityStored`，manual unread 走 generic manual-unread path。两类 event 都携带由 Task attention planner 固化的 bounded recipient snapshot；taskattention package、legacy unread counters 和 Inbox candidate projection 已移除。
10. 不为 stale unread 的在线精确性付出 unbounded 或 request-time 数据库修补代价；not-ready 时允许 suppress unread display。

## 4. 非目标

- 不把数据库 unread projection 表作为常规 serving path。
- 不为每条普通消息的每个 recipient 增加 durable row。
- 不让 page refresh、heartbeat 或 workspace switcher render 触发 broad unread database query。
- 不在进程内存中解决 same-workspace multi-owner execution。
- 不增加 agentteam server 进程之间的 peer-to-peer memory coordination。
- 不把 runtime snapshot 表作为普通 serving path 或写入 path 的 projection 表。
- 不保证 runtime snapshot 在 crash 时包含最后一个 flush interval 内的所有普通内存更新；V1 不自动补齐这段 crash-window unread。已完成 durable targeted Task v2 outbox 的 frozen recipients 是唯一例外。
- 不保证 snapshot hint 是精确 ledger；unread 可以暂时落后或在安全性无法确认时不显示，直到后续 runtime events、下一次 dirty flush 或显式 operator/manual repair 改变状态。

## 5. 部署模型

UnreadRuntime 由拥有该 workspace execution ownership 的 agentteam server 进程持有。

这与 workspace profile contract 对齐：

- `specs/deployment/agentteam-server-workspace-isolation.md` 要求 browser realtime、daemon owner、agent wake、`scopeFanoutRunner` 和当前 background loops 与 workspace owner 同驻。
- `specs/api/server-profile-routing.md` 要求 workspace data-plane request 通过 `ManagedWorkspaceSet.Has(workspaceID)`。
- `internal/workspace/owner/set.go` 定义 `ManagedWorkspaceSet` 作为 process-local execution owner check。

当前目标假设：

1. 一个 workspace 同一时刻由一个 agentteam server owner 处理。
2. Edge Gateway 将该 workspace 的 HTTP 和 socket traffic 路由到该 owner。
3. `profile=all` 下，一个进程拥有所有 workspace unread runtimes。
4. `profile=workspace` 下，进程只拥有 managed workspace set 中的 unread runtimes。

未来 multi-server 支持应使用 Redis/Valkey 这类 external runtime substrate 来承载 unread state、pub/sub 和 short-lived serving data。不应尝试让 agentteam server 进程彼此同步各自私有 heap。直到这类 substrate 存在，任何需要读取某个 workspace unread 的 route 都必须在该 workspace owner 上执行；owner 无法回答时，外部可以不显示 unread，但不能隐藏地 fallback 到 TiDB 查询。

Workspace management routes 属于 central management surface，不是单个 workspace owner 的 data-plane route。Split profile 下 central process 没有 owner-local UnreadRuntime，也不能跨多个 workspace owner 回答 red dot。V1 规则是：

- `profile=all` 可以从本进程 UnreadRuntime 填充 workspace list red dot。
- `profile=central` 下所有返回 `WorkspaceResponse` 的 management surfaces 都不显示 unread，不调用 TiDB unread query，也不尝试 owner-hop。当前包括 `GET /api/v1/workspaces` 和 `PATCH /api/v1/workspaces/:workspaceId`。
- workspace-scoped bootstrap/sidebar/realtime routes 在 workspace owner 上读取 UnreadRuntime。
- 未来若需要 central workspace list red dots，必须引入 external runtime substrate 或 explicit owner-hop API；不能让 central list fallback 到 TiDB unread aggregation。

## 6. Runtime 状态

UnreadRuntime 在内存中保存 hot state，按 workspace、member 和 scope 建 key。具体数据结构由实现决定，但必须表达以下事实：

```text
workspace_id
member_id
scope_id
last_read_seq
effective_from_seq
latest_message_accounted_seq
latest_activity_accounted_seq
last_activity_read_seq
message_unread_count
activity_unread_count
manual_unread
notification_state
membership_visibility
has_unread
dirty
hydration_state
serving_state
scope_roster_state
scope_roster_generation
scope_message_generation
scope_activity_generation
membership_generation
notification_generation
manual_unread_generation
rewind_generation
```

推荐索引：

| 索引 | 用途 |
| --- | --- |
| `(workspace_id, member_id) -> workspace hasUnread aggregate` | O(1) 回答 workspace red-dot 和 document-title attention boolean |
| `(workspace_id, member_id, scope_id) -> scope unread state` | 从内存回答 sidebar 和 active scope badge |
| `(workspace_id, scope_id) -> active member state refs` | scope 收到消息时更新受影响 member state |
| `(workspace_id, scope_id) -> eligible member roster` | message/durable non-message activity 写入时 O(eligible members) 更新；独立于 visible page lazy restore |
| dirty entry queue keyed by `(workspace_id, member_id, scope_id)` | coalesce runtime snapshot writes |
| pending scope frontier marker keyed by `(workspace_id, scope_id)` | scope roster 未 ready、事件丢失或预算溢出时记录状态/指标；V1 不用它自动调度 durable count top-up/replay |
| pending runtime events keyed by `(workspace_id, scope_id)` | scope roster 未 ready 时临时保存当前进程收到的 message/activity events；roster warm 后只重放这些内存事件，不读取 durable tail |
| snapshot hydration queue keyed by `(workspace_id, member_id)` or explicit scope page | 只约束 snapshot load；不调度 message/activity/legacy task unread count repair |

Workspace-level unread 只允许是 `hasUnread` 红点聚合，不维护 workspace unread count。`(workspace_id, member_id) -> workspace hasUnread aggregate` 是从该 member 已在内存中的 scope states OR 出来的 runtime index，可以同步维护或按 loaded entries 重新计算；它不是 durable row，不写 snapshot 表，也不能驱动 workspace 级 count API。未加载或 not-ready scope 不参与精确 OR，外部可以不显示 unread，不能为补齐 workspace aggregate 去 TiDB 扫描全量 scopes。

Exact scope unread count 总要在某处付出 per-member accounting 成本。目标设计是在 workspace owner memory 中为 scope badge 和 scope-local state 付这个成本，使更新可以 coalesce，realtime push 可以本地完成。目标不是让每次 refresh 去 count messages，也不是让每条消息都立即 durable 更新 per-recipient row。

Scope-level eligible member roster 是写侧状态，不等同于按 active member 或 visible page 懒恢复的 serving state：

- lazy snapshot hydrate 只需要恢复当前 member/page 的 unread serving entries，可以是 partial；
- message/durable non-message activity 写入要给 scope 内所有 eligible members 计数，因此只能在该 scope 的 roster 已经 complete/warm 时走 O(eligible members) 内存增量；
- 如果 scope roster 尚未 ready，写入路径不能同步枚举数据库成员来补齐，也不能只更新已加载的 active users 后假装完整；V1 可以把当前进程收到的 event 放入 bounded in-memory pending queue，并 enqueue 一个按 scope/page 读取 membership roster 的后台 warm job；roster warm 后只重放这些 in-memory pending events。普通 event 不能读取 durable message/activity tail 来补齐 crash-window 或已丢失事件；durable targeted Task v2 outbox 在 frozen recipients 尚未 account 时保持 deferred，重启后按同一个 payload retry；
- operator/manual repair 若存在，读取 bound 必须是显式 member/scope/page/job scope，不能扩大成 workspace 全量成员或全量 scopes；普通 startup/refresh/open-page/heartbeat 不触发它；
- V1 中，重新访问缺失或 suppressed entry 时最多加载 snapshot；不会因为 pending frontier 自动 replay durable message/non-message activity tail。

因此，warm write path 的增量正确性依赖完整 in-memory roster。Cold roster path 在 V1 接受 best-effort degradation：不做 request-time 或 write-time unread 数据库聚合，也不做自动 durable frontier catch-up。只要进程仍存活且 pending queue 未超预算，后台 roster warm 后可以把已接收的 pending events 追回到内存；进程 crash、event queue overflow 或事件丢失后仍只依赖 snapshot hint 或显式 operator/manual repair。

## 7. 事件输入

UnreadRuntime 必须由 durable message-domain write 成功后的事件更新。在 durable write 可见前更新内存可能制造 phantom unread state，因此不允许。本文中的 activity event 只指 `scope_activity_events` 中的 durable non-message activity；daemon 上报的 `agent:activity` / `agent:status` / `agent:session` / live trajectory 是 runtime telemetry，不是 unread event source，不能 feed `NonMessageActivityStored` 类事件，也不能推进 scope activity frontier。

必须支持的事件类型：

| 事件 | Runtime 影响 |
| --- | --- |
| `MessageStored` | 对 human-visible message 为 eligible member/scope message unread state 增量计数；Task Message 携带 bounded attention recipient snapshot，不按全部可见 participants 计数 |
| `NonMessageActivityStored` | 对 unread-eligible durable non-message activity增量计数；Task typed Activity 只对 committed attention snapshot 中的 Human recipients 计数 |
| `ReadCursorAdvanced` | 清理或减少一个 member/scope unread state，并更新 workspace hasUnread aggregate |
| `ActivityReadCursorAdvanced` | 清理或减少一个 member/scope activity unread state，并更新 workspace hasUnread aggregate |
| `ManualUnreadSet` | 为一个 member/scope 设置 manual unread，并更新 workspace hasUnread aggregate |
| `ManualUnreadCleared` | 显式 marker 被移除或 read state 覆盖时清理 manual unread |
| `NotificationPolicyChanged` | 更新 eligibility；如果无法用 mutation delta 安全更新，标记 affected entries not-ready/suppressed，不自动 rebuild unread count |
| `MembershipChanged` | add、remove、archive、unarchive 或修改 member/scope visibility |
| `MessageRewound` | 对 rewind 删除或回退的 human-visible message facts，在内存中按已计入成员做 decrement |
| `ScopeStateChanged` | scope archived、restored 或 repaired 时更新 state；不能把普通 rewind 作为 broad invalidation 处理 |
| `MessageVisibilityChanged` | 标记受影响 scope/member pair not-ready/suppressed；只有显式 operator/manual repair 才能 recompute |

当前 message transaction boundary 是 `internal/store/message/scope_applier.go` 中的 `ApplyMessagePlanTxWithSidecarWriter`。该边界已经一起应用 `scope_messages`、membership mutations、notification mutations、outbox writes、sync dirty state 和 scope counters。Unread event integration 应位于这个 durable write boundary 之后，或位于等价的 post-transaction owner hook 之后。

如果事件丢失、乱序或因 backpressure 被拒绝，受影响 runtime entry 必须标记为 `snapshot_serving`、`hydrating`、`stale` 或 `unavailable`。Server 内部不允许把 stale exact counts 当成 ready；外部兼容响应可以继续显示已加载 snapshot hint，或在安全性无法确认时 suppress unread display。V1 不对 active working set 做自动 frontier recovery，也不能把异常事件升级成同步 durable truth repair，不能周期性扫全 workspace 来追求 unread 精确性。

事件可靠性闭环必须满足：

1. 每个会影响 unread 的 durable mutation 在同一事务内推进 canonical frontier/generation。Message append 至少推进 scope message frontier；durable non-message activity append 至少推进 scope activity frontier；read cursor、manual unread 和 rewind 至少推进对应 owning row 的 version/generation；membership、visibility、notification 与其它 attention-eligibility mutation 同时推进 owning facts 和 `scopes.roster_generation`。
2. Post-commit runtime event 携带 `(workspace_id, scope_id, member_id?, message_seq?, activity_seq?, generation, roster_generation?, notify_principal_member_id?)`，runtime apply 必须幂等。重复事件不能重复计数；旧 generation 不能覆盖新 generation。MessageStored 的 notify principal 缺省为 message author member。
3. Eligibility mutation event 携带提交后的 `roster_generation` 并推进 runtime complete-roster generation。MessageStored 在 scope serialization boundary 捕获 `required_roster_generation=scopes.roster_generation`。Runtime 在 complete roster generation 达到 required generation 后执行 accounting；较新的 MessageStored 在同一 scope 的 bounded pending queue 中等待对应 roster warm。
4. 如果普通 event commit 成功后进程 crash，或 post-commit event hook 没执行，重启 lazy hydrate 只加载并信任最近一次 snapshot；它不会通过 frontier/generation mismatch 自动追赶 crash-window count。唯一例外是 durable targeted Task v2 outbox：该 outbox 在成为 delivered 前必须完成下文定义的 exact-recipient snapshot barrier。
5. Auditor 若存在，只扫描 runtime 已知 active scopes/members、dirty backlog 或显式 warmup/job scope 的 health/safety 状态；它不能周期性扫描所有 workspace membership、所有 snapshot rows 或 durable unread tails 来修复 count。
6. 现有 durable outbox 可以用于保证未来事件投递或 diagnostics，但不能把 unread count 写成 durable serving projection，也不能在 V1 普通 serving path 中驱动 automatic catch-up。Durable targeted Task v2 outbox 的 bounded completion barrier 只持久化该 event 已冻结 Human recipients 的当前完整 runtime entries，不扫描 durable tail、不枚举 Task roster，也不把 snapshot 表泛化为 projection。

## 8. 更新语义

### 8.0 Task scope unread 纳入方式

Task scope unread 是 UnreadRuntime serving state 的一部分。Workspace `hasUnread`、sidebar badge、Task Inbox row、task detail / related-task unread fields 和 realtime unread deltas 都从 runtime state 反映 targeted Task Message、targeted typed Activity 与 manual unread。

Task unread serving 只有一个 owner：owner-local UnreadRuntime。Durable DB rows 仍是事实来源，但不再有第二套 durable task unread counter projection：

- Task Message facts：`scope_messages` / scope read cursor 是 canonical facts；message outbox 保存 `watchers ∪ assignee ∪ message mentions − actor` 的稳定 snapshot，committed Task Message 只为 snapshot 中的 Human feed `MessageStored`；
- task typed Activity facts：`scope_activity_events` 中 `activity_ref_type='task_event'` 的 rows 是 task timeline/history canonical facts；pure Activity outbox 保存 `watchers ∪ assignee − actor`，只为 snapshot 中的 Human feed `NonMessageActivityStored`；
- task read facts：message cursor feeds `ReadCursorAdvanced`，activity cursor feeds `ActivityReadCursorAdvanced`；Task mark-read 推进两者；
- manual unread facts：`conversation_member_states.manual_unread_*` 仍是 durable marker，serving 由 runtime `ManualUnreadChanged` / read cursor state 表达；
- Task Watch facts：`scope_notification_settings` 只保存 explicit Watch / Unwatch；assignee 与 direct mention 是 event-level eligibility，不持久化为 Watch；
- Task conversation facts：member-local `im_conversation_index` 保存一个 `kind=task` 的聚合 row，message 与 Activity 共同更新这一行。

`internal/store/taskattention` 和 legacy task unread/Inbox compatibility cleanup 已移除。`conversation_member_states.task_message_unread_count`、`task_activity_unread_count`、`task_inbox_active`、`task_inbox_sort_at` 以及只服务这些字段的 indexes 不再是当前 schema。Global Inbox serving paths must not read them as task candidate truth or write them as a reminder projection.

Runtime snapshot 继续是 interval dirty flush / trusted-on-restart 的 best-effort hint。Startup、refresh、open-page 和 heartbeat path 不读取 removed columns、legacy taskattention state、`scope_messages` tail 或 `scope_activity_events` tail 来 top up task unread。若 runtime state not-ready，serving surfaces suppress unread or show an already-loaded snapshot hint；它们不 fallback 到 legacy counters，也不通过扫描最新 assignee / Watch 重算旧事件 recipients。Durable targeted Task v2 delivery 是有界例外：outbox 已冻结 recipients，因此完成前可以同步 upsert 这些 exact recipients 的当前 Task runtime snapshots。

### 8.1 消息写入

对一条 non-task scope human-visible stored message：

1. 从 stored message result 中识别 workspace、scope、message seq、author member、effective notify principal member、message type 和 created time；普通 message 的 effective notify principal 默认为 author member。
2. 检查 scope 的 eligible member roster 是否 ready。
3. 如果 roster ready，读取完整 in-memory eligible member set。
4. 排除 effective notify principal member。
5. 排除 notification state、membership state、visibility 或 effective seq 使消息不应计入 unread 的 members。
6. 在内存中 increment 每个 eligible member/scope message unread state。
7. 更新每个受影响 member 的 workspace hasUnread aggregate。
8. 标记受影响 member/scope entry 为 dirty，等待后台 snapshot flush。
9. 向受影响 online users 的 connected sockets 推送 unread deltas。

如果 roster 不 ready：

1. 不在 message write path 同步查询 TiDB 来枚举成员。
2. 不只更新已加载的 active users 后把 scope 标记为 complete/ready。
3. 将当前进程收到的 event 放入 bounded in-memory pending queue，并 coalesce 一个后台 scope roster warm job；该 job 按页读取 `scope_memberships` / notification facts，只恢复 write-side roster context。
4. 可以记录 pending/stale state、低基数指标或 suppress affected loaded entries。
5. 对外可以继续服务已加载 snapshot hint，或在缺少 snapshot / 安全性无法确认时不显示 unread。
6. roster warm 后只重放 in-memory pending events；不 replay durable message tail，也不读取 durable unread truth 来补齐 crash-window 或 cold-roster unread count。

Warm roster runtime 工作量是：

```text
O(eligible members in the message scope)
```

Cold roster inline runtime 工作量是 O(1) pending/stale bookkeeping 和 queue enqueue。后台 roster warmup 工作量是 O(scope roster page count)，由 hydrate concurrency / batch size / event queue budget 限制；V1 没有 durable tail catch-up 工作量。

Message write path 上的 UnreadRuntime 数据库工作是：

```text
0 unread reads
ordinary Message: 0 immediate UnreadRuntime snapshot/projection writes
durable targeted Task v2 completion: O(frozen Human recipient count) exact snapshot upserts
```

Task Message path follows the same runtime message accounting contract with `ScopeTypeTask`, but eligibility is targeted rather than dense. The canonical message transaction writes `scope_messages`, the stable Task attention recipient snapshot, one message outbox, and timeline facts where needed. Runtime applies the committed message exactly once to snapshot Human recipients. The paired `discussion_added` Activity must not feed a second unread event. It does not enumerate the full Task roster, write legacy Task counters / candidate rows, or read removed taskattention state.

Durable message write 仍然写 canonical message facts，并推进 scope message frontier/generation。普通 Message 的 UnreadRuntime write path 只更新已具备完整 runtime context 的内存 entries、dirty queue、not-ready bookkeeping 和 realtime deltas，不同步写 runtime snapshot。Durable targeted Task v2 outbox 在 stable browser projection 与 exact-recipient memory accounting 成功后、Agent wake 与 outbox completion 之前，同步 batch-upsert frozen Human recipients 的当前完整 Task snapshot rows。Roster 尚未 account、runtime/barrier 未配置或 snapshot upsert 失败时，outbox 必须 deferred，不能 wake Agent、不能标记 delivered，且 defer 不消耗真实 delivery failure budget；retry 复用稳定 event id 和 idempotent runtime generation。普通 Message/read/refresh 仍不触发该 barrier。后续 interval flush 仍可为 dirty entry 写 coalesced row。

### 8.2 已读状态

对 mark-read、mark-all-read 或 read cursor advancement：

1. 在 durable storage 中写 canonical cursor state。
2. 写成功后，更新对应 in-memory member/scope state。
3. 清理或减少 `message_unread_count` 和/或 `activity_unread_count`。
4. 当 read cursor 覆盖 manual marker 时清理 `manual_unread`。
5. 更新 workspace hasUnread aggregate。
6. 标记 entry 为 dirty，等待后台 snapshot flush。
7. 向该 member 的 connected sockets 推送 unread deltas。

单 scope runtime 工作量是 O(1)，显式 multi-scope mark-read 操作是 O(selected scope count)。除非产品操作明确是“mark all visible scopes read”，并且实现有有界 page 或 job boundary，否则该操作不能枚举所有 visible scope。

Task scope read/manual-unread paths write their canonical message cursor, activity cursor and manual marker facts, then feed runtime cursor/manual events without legacy cleanup. Mark-read advances both Task cursor domains and clears the one aggregated Task entry. Runtime does not read removed taskattention rows to repair count and does not write retired durable Task Inbox fields.

### 8.3 Durable non-message activity unread

Workspace red dot 是 message unread、unread-eligible durable non-message activity unread 和 manual unread 的 OR。Conversation Message、targeted Task Message、targeted typed Activity 与 manual unread 由 UnreadRuntime accounting；removed taskattention rows 不作为 unread counter owner。Daemon telemetry、agent runtime activity、presence、session 和 live trajectory 不参与。

对一条 Task `NonMessageActivityStored`：

1. 事件携带 workspace、scope、activity seq、actor member、activity type、created time 与 committed Human recipient member ids；
2. Runtime 只更新 snapshot 中仍可读取该 Task、且 activity cursor 尚未覆盖该 seq 的 recipients；
3. actor 已由 planner 排除，runtime仍应 fail-safe 拒绝 self-recipient；
4. 更新对应 Task entry 的 `activity_unread_count`、workspace `hasUnread` aggregate、dirty queue 与 realtime delta；
5. 更新同一 member-local `im_conversation_index` Task row，而不是创建 per-Activity Inbox item；
6. durable targeted Task v2 outbox 在 wake / delivered 前，对 frozen Human recipients 执行同一 exact snapshot barrier；pending 或 persistence failure 时 deferred，不能 wake / complete；
7. 重复 outbox / runtime event 按 activity id / seq 幂等。

Task Activity accounting 是 O(snapshot Human recipient count)，不要求加载或枚举完整 Task roster。Activity read cursor advancement 使用 `ActivityReadCursorAdvanced` 清理 Task `activity_unread_count`。Runtime 不合成 Message，不把 legacy taskattention 当 counter，也不写 retired Task projection columns。

### 8.4 Notification 和 Membership 变化

Notification 和 membership change 可能改变已有 message/durable non-message activity tail 是否计入 unread。Runtime 必须优先保证 safety，而不是自动修复 count：

- 如果 mutation path 能提供精确 delta，可以直接更新当前 memory 并标记 dirty。
- 如果无法在 mutation path 中安全界定影响范围，V1 将 affected entries 置为 not-ready/suppressed 或继续服务已加载 snapshot hint；不自动读取 durable tail recompute。
- 如果 change 会让 scope/member unauthorized、removed、archived 或 invisible，必须避免继续显示该 scope unread。
- 外部兼容响应可以返回 false/empty 以 suppress unread display，但该值不能被解释为 durable truth 已证明没有 unread。

### 8.5 Rewind、Archive 和 Repair

Message rewind 不能直接把整个 scope、workspace 或 member unread state 标记 stale 来逃避计算；这会把一次 rewind 变成后续 recovery 对 TiDB 的额外压力。Rewind mutation path 已经知道被删除或回退的 message range，必须在 durable write 成功后向 runtime 发送足够的 removed message facts：

```text
workspace_id
scope_id
message_seq
author_member_id
notify_principal_member_id
message_type
created_at
```

Runtime 对这些 human-visible removed messages 做反向 accounting：

1. 读取 scope 的 in-memory eligible member set。
2. 排除 removed message fact 中的 effective notify principal member。
3. 只对该 message 曾经满足 `message_seq > last_read_seq`、`message_seq >= effective_from_seq` 和 policy/visibility 条件的 member decrement。
4. 更新受影响 member/scope unread state 和 workspace hasUnread aggregate。
5. 向 online users 推送 unread deltas。

Runtime 工作量是：

```text
O(removed human-visible messages * eligible members in the affected scope)
```

实现可以按 member 聚合 removed seq ranges 来减少常数，但不能把普通 rewind 降级成 broad hydrate/rebuild。只有当 runtime entry 本来就不是 ready，或者 durable mutation 缺少足够的 removed message facts 时，才允许 suppress/not-ready；V1 不自动进入 durable tail recovery。

Archive、private visibility repair 或 data repair 也可能减少之前已经计数的 unread messages。能提供精确 removed/visibility-change facts 的流程应按 delta 更新内存；影响范围无法在 mutation path 中界定时，标记受影响 entries not-ready/suppressed。不能依赖下一次 heartbeat database query 或自动 recovery 来修正状态。

## 9. Runtime Snapshot 和恢复配置

Runtime snapshot 是 UnreadRuntime 内存状态的周期性恢复快照，不是 unread projection 表。推荐 schema：

```text
member_scope_unread_runtime_snapshots(
  workspace_id,
  member_id,
  scope_id,
  message_unread_count,
  activity_unread_count,
  manual_unread,
  last_message_read_seq,
  last_activity_read_seq,
  effective_from_seq,
  latest_message_accounted_seq,
  latest_activity_accounted_seq,
  notification_state,
  membership_visibility,
  scope_roster_generation,
  scope_message_generation,
  scope_activity_generation,
  membership_generation,
  notification_generation,
  manual_unread_generation,
  rewind_generation,
  task_snapshot_format_version,
  task_snapshot_json,
  updated_at,
  PRIMARY KEY (workspace_id, member_id, scope_id)
)
```

Generation columns 保存 snapshot 写出时 runtime 已经 account 的 durable frontier。V1 普通 serving path 不用这些字段做 automatic freshness check；它们用于 diagnostics、operator/manual repair、observability 和未来 V2 演进。

不新增 workspace-level snapshot 表，也不新增 workspace unread count。Workspace red-dot `hasUnread` aggregate 从加载进内存的 member/scope entries 聚合，是进程内派生 index。

Snapshot flush contract：

- Flush 只写 dirty entries；未变化 entry 不重复写。
- Heartbeat、refresh、workspace list、sidebar read 和 socket reconnect 都不能把 entry 标记 dirty。
- Message、durable non-message activity、task typed activity、read cursor、activity read cursor、manual unread、policy、membership、visibility、task suppress state 和 rewind delta 只有在更新当前 runtime memory 时才改变 dirty state。
- 普通 explicit write path 更新 memory 后只标记 dirty；message/read/manual/policy/refresh/heartbeat 不能同步 flush snapshot。
- 唯一同步持久化例外是 durable targeted Task v2 outbox completion barrier：只 batch-upsert 该 outbox 已冻结 Human recipients 的 exact Task entries，且必须发生在 Agent wake / delivered 之前。它不 drain 全局 dirty queue、不扫描 roster/tail，也不改变其他 entry 的 interval-flush contract。
- Dirty entries 按 `(workspace_id, member_id, scope_id)` coalesce。
- 同一 flush interval 内，同一个 dirty entry 因多次内存变化最多写一条 snapshot row。
- Flush batch 有显式最大 row count、retry/backoff 和 observable backlog。
- Flush failure 不能阻塞普通 request read；当 dirty backlog 超过预算时可以对 message/read mutation path 施加 backpressure，或将受影响 entries 标为 not-ready/suppressed。
- Targeted Task v2 barrier failure 必须让对应 outbox deferred/no-wake/no-completion；已经产生的 live delta 可以发出，retry 通过稳定 event id/generation 幂等地重试 barrier。

Targeted Task v2 的 rolling-deploy fence 复用同一 snapshot row 上的 nullable
versioned Task shadow；它不是另一张 unread projection：

- 新 barrier writer dual-write regular columns 和 Task shadow，旧 reader 因而仍可只读
  regular columns；旧 writer 不认识 additive shadow columns，后到的 regular upsert
  不能擦除已经完成的 barrier。
- 普通 periodic flush 继续只更新 regular aggregate，不能用一个可能来自另一 owner
  heap 的 aggregate 覆盖 shadow。新 reader 在 shadow 存在时以 shadow 的 Message /
  Activity aggregate 及其 scope/rewind generation 为准，只 overlay generation 可证明更晚的
  membership/notification/manual control，或完全覆盖 protected frontier 的 read
  cursor/effective-from；partial intersection 必须 suppress/defer，不能猜 count。
- 首次建立 shadow 时，已有 format-0 regular row 只作为 V1 control hint：其较大的
  opaque frontier、scope generation 或 rewind generation 不能阻止一个较早但
  已完成 accounting 的 targeted-v2 barrier 落盘；control 仍按上述 full-cover /
  partial-intersection 规则处理。
- 后续 barrier candidate 不得倒退 protected frontier、generation、cursor 或未被完整
  clear 的 protected count；验证失败时 outbox 保持 retryable，不能先 wake /
  delivered。
- Aggregate snapshot 不能证明两个 owner 独立 account 的 seq set union。rolling
  窗口内旧 binary 新产生的普通 V1 Task event 仍遵守既有 crash-window
  best-effort，不得声称 shadow merge 可以无损恢复该事件。
- 该 fence 建立在现有 single workspace-owner runtime contract 上，只解决 rolling
  old-writer late upsert；它不是 multi-owner CRDT。若未来允许两个 owner 并行
  account Task attention，必须引入有 provenance 的 CAS 或 bounded event ledger，
  不能继续合并 opaque counts。
- 回滚不是“先启动旧 reader”即可：必须先停止 snapshot writers、drain/suppress
  targeted-v2 delivery，并在旧 reader 启动前通过 migration/repair gate 把所有
  supported shadow reconciliation 回 regular columns。Repair 必须 materialize
  与新 reader 相同的 `shadow + safe regular controls`：保留 full-cover
  read/effective-from 与 newer generation-owned controls，opaque event state
  仍取 shadow，partial intersection 必须 gate 失败；unsupported/损坏 shadow
  也必须阻止 contraction，不能直接 drop additive columns。

TiDB 仍然是 durable product facts 的 source of truth：

```text
scope_messages
scope_activity_events (message-domain durable non-message activity only)
scope_memberships
scope_notification_settings
scopes
members
conversation_member_states manual unread facts and legacy task Inbox compatibility columns
tasks status/policy facts, when the scope is a task scope
```

但 V1 的 unread startup/refresh/open-page/heartbeat path 不读取这些 durable facts 来自动修复 runtime count。它们只能用于 normal product writes, explicit diagnostics, shadow checks in controlled mode, operator/manual repair, or future V2 recovery.

冷启动恢复：

1. AgentTeam server 启动后创建空 UnreadRuntime，并启动 snapshot flush / snapshot hydrate worker。
2. 启动过程不全量读取 snapshot 表。
3. 用户进入 workspace、socket connect、打开 sidebar/visible page、或显式后台 warmup 可以 enqueue `(workspace_id, member_id)` 或 `(workspace_id, member_id, scope_page)` snapshot hydration job。
4. Hydration job 按 member/scope page 读取 runtime snapshot rows，恢复内存 state，并从这些 rows 聚合已加载 page 的 workspace hasUnread aggregate。
5. 未加载 page 不参与当前内存 aggregate；外部可先不显示这些未加载 scope 的 unread。
6. Snapshot page 恢复到内存后即可从内存 serve snapshot value；内部 state 是 `snapshot_serving`，不是 exact ledger。
7. V1 不在 snapshot load 后执行 metadata frontier check、message/activity/task-activity tail replay、legacy task unread projection recovery 或 membership/policy count reconciliation。
8. Snapshot 缺失、未加载或安全性无法确认的 entries 可以不显示 unread。
9. 后续 refresh、sidebar、workspace red-dot 和 socket unread deltas 都只读内存。

`Snapshot_serving` 是可服务的内存 hint，不是 exact-ready truth。旧 wire format 只有 boolean/count 时，可以直接返回 snapshot value 以避免冷启动首屏阻塞；如果某个 surface 要求安全过滤，必须在 unauthorized、removed、archived、invisible 或 corrupt snapshot 情况下 suppress unread。无论选择哪种 UI 表达，server 都不能在 `snapshot_serving`、`hydrating` 或 `stale` 时同步 fallback 到 TiDB broad unread aggregation。

因此，启动恢复不能退化为：

```text
O(snapshot entries * unread tail count)
O(snapshot entries * legacy task/read/membership reconciliation)  // legacy task unread projection recovery is forbidden in serving paths
```

目标恢复形态是：

```text
O(restored snapshot page)
```

Runtime budgets 和默认值分两层。V1 只把需要运维按部署规模调节的项暴露为 operator-facing configuration；其余是 runtime internal budgets，必须有代码默认值和测试，但不要求通过配置文件或环境变量暴露。

Operator-facing configuration：

| 配置 | 默认目标 | 含义 |
| --- | --- | --- |
| `AGENTTEAM_UNREAD_RUNTIME_SNAPSHOT_FLUSH_INTERVAL` | `30s` | 将 dirty unread runtime entries flush 到 snapshot storage 的周期。 |
| `AGENTTEAM_UNREAD_RUNTIME_SNAPSHOT_FLUSH_BATCH_SIZE` | `1000` | 单次 snapshot flush 最多写入的 dirty entries。 |
| `AGENTTEAM_UNREAD_RUNTIME_HYDRATE_MAX_CONCURRENCY` | `2` | 每个 process 同时执行的 snapshot hydrate job 上限。 |
| `AGENTTEAM_UNREAD_RUNTIME_HYDRATE_BATCH_SIZE` | `100` | 单个 snapshot hydrate step 最多处理的 member/scope entries。 |

Runtime internal budgets：

| Budget | 默认目标 | 含义 |
| --- | --- | --- |
| hydrate retry interval | `5s` | 处理 failed snapshot hydration retry 的 tick；不表示扫描 stale entries，也不驱动 count repair。 |
| event queue size | `10000` | 每个 process 可排队的 unread runtime events 上限。 |
| accounting batch size | `500` | 单个 event apply tick 最多同步 account 的 member entries。 |
| max sync accounting members | `2000` | 单条 message/durable non-message activity event 可在调用 goroutine 中同步 account 的最大 eligible member 数。超过后 V1 将受影响 entries 降级为 not-ready / pending metric，不自动切到 chunked background accounting 或 durable fact replay 来补齐 count。 |

Snapshot flush interval 默认不应过短。Snapshot 是 restart/lazy serving hint，不是 exact ledger；较短 interval 会让活跃 entry 在持续消息或 read-state 变化下重复写 snapshot，形成不必要的后台 DB 写压力。默认 `30s` 接受最多一个 flush interval 加 backlog delay 的 snapshot 落后；需要更小 crash-window 的部署可以显式调低，但不能把调低后的 snapshot flush 当成 unread 精确性的保证。

Operator-facing configuration 必须有 server-side defaults；启动不要求配置文件或环境变量显式指定。配置 loader 应先填入默认值，再应用 operator override，并通过 server wiring 传入 runtime，不能在 worker 中硬编码。Duration 必须为正数；flush batch size、hydrate concurrency 和 hydrate batch size 必须为正整数。实现可以增加更严格的最小值或最大值，但非法 override 必须 fail fast，不能静默回落到默认值。

Runtime internal budgets 必须由 UnreadRuntime 拥有默认值，并在 runtime constructor 或等价初始化边界验证为正数。V1 不要求把它们暴露成 `AGENTTEAM_*` 环境变量；它们属于实现预算，用于保护内存、锁持有时间、event queue 和大 scope accounting。未来只有出现明确运维调参需求时，才应把某个 internal budget 提升为 operator-facing configuration。

Crash 语义：

- 进程 crash 会丢失全部 unread memory。
- Durable message/read/policy/membership/task facts 仍然是 authoritative product facts。
- Runtime snapshot 可能落后于 durable facts，最多落后一个 flush interval 加 backlog delay。
- Restart 后 runtime 可以从 snapshot 立即恢复并 serve snapshot value；V1 信任 snapshot，不自动 replay tail 或读取 legacy task unread projection/membership/policy rows 来补齐。
- 已标记 delivered 的 targeted Task v2 outbox 是唯一更强保证：其 frozen Human recipients 的对应 Task snapshot rows 已越过 completion barrier；若 barrier 未完成，row 保持 retryable 而不是先 wake / delivered。
- rolling old binary 后到的 regular snapshot upsert 不能覆盖 targeted Task v2
  shadow；restart reader 不能用 legacy aggregate 的较大 frontier 猜测它已经包含
  protected event。
- Snapshot 不能被 request path 当作数据库 projection 查询；它只能被 snapshot hydrate worker 读入内存。

## 10. Hydration 和 Repair

Hydration state 必须在 server 内部显式表达。Runtime entries 应具备以下状态：

```text
ready
snapshot_serving
hydrating
stale
unavailable
```

- `ready`：当前进程存活期间由 committed runtime events 维护的 owner-local memory value；这是 warm serving truth。
- `snapshot_serving`：snapshot row 已读入内存，可以作为 best-effort hint 服务；它不是 exact ledger，也不会在 V1 中被 startup/open-page 自动校验后提升为 exact-ready。
- `hydrating`：正在加载 snapshot，或没有可服务 snapshot。
- `stale`：runtime 已知某个 mutation 无法安全给出 exact delta；默认 suppress unread display 或继续显示安全的 snapshot hint。
- `unavailable`：runtime owner 不可用、owner mismatch、snapshot corrupt/incompatible、unauthorized/invisible，或预算保护拒绝服务。

Warm browser refresh：

1. Server process 仍然存活。
2. Workspace owner 已经在内存中持有 member/scope unread state。
3. HTTP unread endpoints 和 socket unread pushes 从内存读取。
4. Unread database reads 为 0。

Cold process start：

1. Workspace owner 以内存空状态启动。
2. 它按 active users、visible pages 或 managed workspace warmup budgets enqueue snapshot hydrate jobs。
3. Snapshot hydrate 只读取 `member_scope_unread_runtime_snapshots` 的 bounded page，并信任读出的 value。
4. 它不会在 first paint、open page、refresh 或 heartbeat path 同步执行 broad exact unread aggregation、metadata reconciliation、durable tail replay 或 legacy task unread projection recovery。
5. Snapshot 缺失、corrupt/incompatible 或安全性无法确认时，外部可以不显示 unread。

Missing runtime state：

1. Server 内部把 entry 标记为 `hydrating`、`stale` 或 `unavailable`。
2. 外部兼容 API 可以 suppress unread display，返回 false 或 empty counts。
3. 重新访问最多调度 bounded snapshot hydrate；不调度 durable-fact rebuild 来修复 count。
4. 显式 operator/manual repair 可以另行定义 durable fact reads，但不属于 V1 普通 serving path。

任何 hot path 都不能通过 fallback 到 broad `COUNT(scope_messages)`、policy `EXISTS` scan、legacy task unread projection read 或 bounded tail replay 来隐藏 cold start。短期产品语义允许“不显示 unread”或显示 stale snapshot hint，但不允许为 unread correctness 保留 request-time TiDB 风暴风险。

## 11. API 和 Realtime 契约

目标 serving surfaces：

| 服务面 | 目标 unread 来源 |
| --- | --- |
| Workspace list red dot | `profile=all` 可读本进程 UnreadRuntime；`profile=central` V1 不显示 unread；未来 external runtime 或 owner-hop 才能跨 owner 显示 |
| Workspace shell bootstrap | owner UnreadRuntime `hasUnread` aggregate，不使用 exact database counts |
| Sidebar row badges | visible 或 subscribed scopes 的 owner UnreadRuntime scope counts；not-ready 时不显示 |
| Active channel/thread badge | 该 scope 的 owner UnreadRuntime state；not-ready 时不显示 |
| Socket heartbeat | 不做 unread database read，只承担 liveness |
| Realtime unread event | owner UnreadRuntime 在 event application 后发布 deltas |

旧 compatibility endpoint 已从普通 Web serving path 移除。若未来迁移窗口临时引入兼容 surface，目标实现仍必须 memory-backed；兼容性不能成为长期保留 request-time database aggregation 的理由。

当 exact counts 尚未 ready，server 内部必须保留显式 state，而不是静默从 TiDB 计算 exact counts。`snapshot_serving` 可以返回 snapshot value 作为 best-effort hint；`hydrating`、`stale` 和 `unavailable` 默认让 UI 不显示 unread。外部 wire format 可以继续使用旧兼容响应，例如：

```json
{
  "hasUnread": false,
  "counts": {}
}
```

具体 wire shape 由实现时的 API spec 拥有。持久要求是 server 不能把兼容 false/empty 当作 durable zero truth，且不能在 not-ready 时 fallback 到数据库 unread 查询。未来如果产品需要 UI 表达 pending unread，可以在不改变 runtime 语义的前提下扩展 wire state。

## 12. 复杂度契约

Warm-path 目标：

| 操作 | Unread DB 读 | Unread DB 写 | Runtime 工作 |
| --- | --- | --- | --- |
| 已经 warm 的 workspace browser refresh | 0 | 0 | O(1) workspace hasUnread aggregate；如果请求 scope counts，则加 O(visible badge count) |
| Socket reconnect | 0 | 0 | O(1) cached hasUnread emit |
| Socket heartbeat | 0 | 0 | O(1) liveness handling |
| Non-Task Message write with warm roster | 0 | 0 immediate Runtime snapshot writes | O(eligible members in the message scope) |
| Non-Task Message write with cold roster | 0 inline unread reads；background roster warm reads one scope roster by page | 0 immediate Runtime snapshot writes | Inline O(1) pending/stale bookkeeping；background O(scope roster page count + pending events * eligible members)，no durable tail replay |
| Durable targeted Task v2 Message completion | 0 unread reads | O(frozen Human recipients) exact snapshot upserts | O(bounded recipient snapshot size)，no full Task roster scan；barrier failure deferred/no-wake/no-completion |
| Durable targeted Task v2 typed Activity completion | 0 unread reads | O(frozen Human recipients) exact snapshot upserts | O(bounded recipient snapshot size)，no full Task roster scan；barrier failure deferred/no-wake/no-completion |
| Message rewind | 0 | 0 | O(removed human-visible messages * eligible members in affected scope)，可按 member/range 聚合 |
| Single-scope mark-read | 0 unread reads | canonical cursor write | O(1) |
| Explicit mark-all-read | 0 unread reads | canonical cursor writes | O(selected scope count)，带显式 bound/job |
| Cold snapshot restore | bounded snapshot reads only | 0 | O(restored member/scope page)；进入 `snapshot_serving` 后不自动验证/replay |
| Missing/mismatched entry | 0 on hot path | 0 | O(1) suppress or serve existing snapshot hint；operator/manual repair is out-of-band |

Unread 之外的数据库工作，例如 authentication、workspace access proof、message storage、membership writes 和 canonical read cursor writes，仍由各自 owning specs 约束。本表只约束 unread serving 和 unread cache durability。

## 13. Backpressure 和资源限制

UnreadRuntime 必须有显式预算：

- configured snapshot flush interval；
- snapshot flush batch size 和 retry rate；
- snapshot hydrate retry interval；
- 每个 process 的 hydrate max concurrency；
- 每个 snapshot hydrate step 的 batch size；
- 每个 process 的 event apply queue depth；
- 单个 event apply tick 的 member accounting batch size；
- 单条 message/durable non-message activity event 的最大同步 accounting member 数；
- 每个 workspace 和每个 process 的最大 hydration jobs；
- inactive member/scope entries 可占用的最大内存；
- batching 前每个 tick 可执行的最大 realtime unread delta fanout；
- dirty snapshot backlog age、snapshot hydration age、snapshot-serving age、stale/suppressed unread responses、dropped events 和 explicit repair failures 指标。

Warm roster message/durable non-message activity accounting 的总工作量仍是 O(eligible members in scope)，但它必须被预算化执行：

- 当 eligible member count 小于等于 runtime internal `max sync accounting members` budget 时，可以在 event apply 中按 runtime internal `accounting batch size` budget 分批更新内存，锁只能覆盖当前 batch。
- 当 eligible member count 超过同步上限，或者 event queue/backlog 已超过预算，runtime 必须把受影响 entries 标记为 `snapshot_serving` / `stale` / `unavailable` 或记录 pending metric；V1 不自动分批读取 durable facts 来补齐 count。
- Realtime unread delta fanout 也按 batch 发送，不能在单个 message write 或单个 runtime lock hold 内向大 scope 全量 online members 同步推送。
- 后台 snapshot hydrate 完成前，server 不能 fallback 到 TiDB unread aggregation；外部可以显示已有 snapshot hint 或 suppress unread。

当预算超出时，runtime 应将 exact counts 降级为 `snapshot_serving`、`hydrating`、`stale` 或 `unavailable` states，并保持 suppress 或等待下一次 snapshot hydrate / explicit repair。它不能切换到 request-time broad database aggregation 作为 emergency fallback。

## 14. 实施计划

实现应一次迁移一个 serving surface，同时保持 runtime owner boundary 显式。V1 的 implementation order 是：先建立 snapshot-backed memory serving，再删除自动 count repair/replay。

### 14.1 配置和存储

1. 在 `internal/config` 下增加 unread runtime configuration，由现有 server configuration loader 拥有：
   - `AGENTTEAM_UNREAD_RUNTIME_SNAPSHOT_FLUSH_INTERVAL`；
   - `AGENTTEAM_UNREAD_RUNTIME_SNAPSHOT_FLUSH_BATCH_SIZE`；
   - `AGENTTEAM_UNREAD_RUNTIME_HYDRATE_MAX_CONCURRENCY`；
   - `AGENTTEAM_UNREAD_RUNTIME_HYDRATE_BATCH_SIZE`。
2. 配置 loader 必须为这些 operator-facing 字段提供默认值。未配置时使用默认值启动；显式配置时验证 duration 为正数，flush batch size、hydrate concurrency 和 hydrate batch size 为正整数，并通过 server wiring 传给 workspace-owner unread runtime。
3. 增加唯一一张 scope-level `member_scope_unread_runtime_snapshots` schema。不新增 workspace-level snapshot 表，不新增 workspace unread count 表，不新增 hot-path unread projection 表，也不新增独立 checkpoint/frontier 表。
4. Store port 的 V1 serving surface 只需要：
   - 按 workspace/member/scope page bounded load snapshot rows；
   - batch upsert dirty member/scope runtime snapshot rows；
   - 按 workspace/scope/member page bounded load scope roster rows，用于 message/activity event cold-roster 后台 warmup。
5. Durable fact reads, legacy task unread projection reads, metadata freshness reads, tail replay and legacy database aggregation may exist only for explicit diagnostics, shadow comparison, migration, or operator/manual repair surfaces. They must not be called by startup, refresh, open-page, heartbeat, or normal snapshot hydrate paths. Scope roster hydrate 只能由 committed runtime event 的 cold-roster path 触发，且必须 coalesce by scope；它不能作为 request-time serving fallback，也不能读取 message/activity tail 修复 count。

### 14.2 Runtime 核心

1. 增加 process-local UnreadRuntime component，位置应在 `internal/store` 之外。Store packages 拥有 TiDB persistence；UnreadRuntime 拥有 in-memory state、snapshot hydrate state、dirty queue 和 realtime unread deltas。
2. Runtime 通过 narrow ports 建模：
   - read port，用于 workspace red-dot 和 scope unread count queries；
   - event port，用于 message、durable non-message activity、task typed activity、read cursor、activity cursor、manual unread、policy、membership、message rewind delta、visibility、archive 和 task suppress/invalidation events；
   - lifecycle port，用于 start、stop、dirty snapshot flush 和 snapshot hydrate scheduling。
3. 实现 sharded 或 per-workspace locking，避免 unrelated workspaces 的 message/read events 争用单个 global mutex。
4. 实现 scope roster state、event apply queue、chunked in-memory member accounting、snapshot-serving state 和 safety suppression。
5. Snapshot hydrate retry 只重试 failed snapshot loads；roster warm retry 只重试已 enqueue 的 cold scope roster warm job；二者都不能 scan all stale entries，也不能 turn pending frontiers into durable count repair.
6. Runtime unit tests must cover non-task message increments, non-task durable non-message activity increments, large-scope budget suppression, event queue backpressure, task scope no double count, notify-principal exclusion with author default, read clearing, manual unread, policy ineligibility, cold roster bounded roster warm with in-memory pending replay, snapshot-serving, not-ready suppression, and trusted snapshot hydrate.

### 14.3 事件接入

1. Durable non-task message write 成功后 feed `MessageStored`，并确保同一事务已经推进 scope message frontier/generation。当前 durable message boundary 是 `ApplyMessagePlanTxWithSidecarWriter`；integration 应发生在该 boundary 之后，或另一个 post-transaction owner hook 之后，不能发生在 uncommitted transaction 内。
2. Durable non-task high-signal non-message activity write 成功后 feed `NonMessageActivityStored`，并确保同一事务已经推进 scope activity frontier/generation。
3. Task Message 走 targeted `MessageStored` accounting with `ScopeTypeTask`；pure Task typed Activity 走 targeted `NonMessageActivityStored`。两者复用 committed recipient snapshot，不 synthetic message、不 scan full roster；paired `discussion_added` Activity 不重复 accounting。
4. Canonical cursor write 成功后 feed read-state 和 activity-read-state events。Task mark-read 同时推进两类 cursor。Single-scope mark-read 是 O(1)；explicit multi-scope mark-read 必须保留显式 selected-scope 或 job bound。
5. 从各自 owning mutation paths feed manual unread、notification policy、membership、scope archive/restore、message rewind delta 和 visibility events。
6. 如果某条 path 暂时无法 emit 精确 delta，只标记受影响 member/scope/workspace entries 为 stale/suppressed 或继续服务 snapshot hint。不要触发 workspace-wide request-time unread query，也不要为了在线精确性扫描 durable facts。

### 14.4 Serving Path 切换

1. 增加 unread read port，供 workspace、channel/read-state、bootstrap 和 realtime code 消费。API handlers 应依赖这个 port，而不是依赖 `internal/store/app` unread count methods。
2. 将 workspace management response unread 处理改为 profile-aware：`profile=all` 可用本进程 UnreadRuntime 填充 red dot；`profile=central` 的所有 `WorkspaceResponse` surfaces V1 不显示 unread，也不调用 TiDB unread query 或 owner-hop；workspace-scoped bootstrap/sidebar/realtime routes 在 workspace owner 上读取 UnreadRuntime。
3. 保持 `listUnreadCounts` / `/channels/unread` compatibility read 从普通 Web serving path 中移除；active workspace 初始 unread 由 snapshot 携带 runtime-backed `unreadCounts`，后续只通过 stream delta 和本地写入口收敛。
4. 移除 socket heartbeat database polling。Heartbeat 只保留 liveness；unread changes 通过 runtime unread delta events 和 cached socket `hasUnread` state delivery。
5. 更新 workspace bootstrap/query-cache flows，使 bootstrap 可以 seed workspace `hasUnread` aggregate，client 在 hydration 后不会立即 refetch broad unread counts。
6. 旧 database aggregation helpers 只保留给 explicit diagnostics、operator/manual repair 或临时 shadow comparison，直到所有 warm serving surfaces 完成迁移。

### 14.5 前端契约

1. 更新 sidebar、workspace switcher、document-title attention 和 refresh helpers，使其消费 memory-backed unread state 和 realtime unread deltas。
2. Runtime not-ready 时不显示 unread，不发起 broad unread API refetch，也不要求 server 同步计算 exact counts。
3. 确保 route-independent workspace shell render 不依赖 exact unread counts。Exact row badges 只在 ready 或 snapshot-serving hint 可安全显示时出现。
4. 增加 frontend tests，证明 bootstrap、refresh 和 socket heartbeat handling 不调用 broad unread endpoints。

### 14.6 切换和清理

1. 每个 serving switch 都要用 query-count 或 SQL-shape tests 作为 gate，证明 warm path 上 unread database reads 为 0。
2. 监控 dirty snapshot backlog age、snapshot hydrate age、snapshot-serving age、stale/suppressed unread responses、shadow mismatches、explicit repair failures 和 dropped events。
3. Warm serving paths runtime-backed 后，移除 first-paint 和 heartbeat 对 `HasUnreadForUserWorkspaces` 以及 broad exact `UnreadCountsForUser` 的调用。
4. Database aggregation 只作为 operator/manual repair 或 diagnostic authority 保留。普通 refresh、heartbeat、open-page 和 workspace switcher paths 不可触达它。

### 14.7 Hot-path DB write optimization layer

This layer narrows the remaining durable writes that are still adjacent to unread-affecting user actions after unread serving has moved to UnreadRuntime. It does not move canonical facts out of TiDB; it only removes writes that no longer change canonical state or that belonged to the retired task unread counter projection.

Implemented in this layer:

1. **Global Inbox Task candidates are recipient-targeted.** The unified Inbox must not use `conversation_member_states.task_inbox_active` or `task_inbox_sort_at`. A targeted Task Message / typed Activity or manual-unread action updates one member-local `im_conversation_index` row. Passive Task visibility and membership do not create Inbox candidates.
2. **Task Inbox projection writes and schema remain retired.** Task attention updates the unified conversation index and owner-local runtime; it must not refresh or recreate retired per-member Task columns. Legacy columns and indexes remain absent from bootstrap and upgraded databases.
3. **Legacy task unread counter cleanup is removed with the columns.** Generic conversation read/manual clear paths clear `manual_unread_*` only. Task read paths must not call compatibility cleanup for old `task_message_unread_count` and `task_activity_unread_count` rows because those columns are no longer current schema.
4. **Durable cursor, manual marker, and policy writes should be no-op guarded.** Generic message read cursor advancement should write only when the stored cursor would advance. Manual unread and notification policy upserts should keep `updated_at` and generation unchanged when the committed value is already present. These guards reduce TiDB row churn without changing the durable source of truth.

Deferred follow-up:

- Legacy task unread counter and task Inbox projection columns/indexes have been dropped from current schema; do not reintroduce compatibility cleanup paths for them.
- Further narrowing task detail read-state membership ensure would require separating active-row existence/repair from cursor advancement without introducing another read-before-write on the hot path. Until then, that path remains a bounded canonical membership/cursor write, not an unread counter projection.
- Stronger crash-window recovery, durable unread event replay, or external runtime substrate remains a later design and must not be hidden behind request-time repair.

Non-goals for this layer:

- Do not introduce new tables, durable unread counter projections, broad repair jobs, DB tail replay on refresh/open/heartbeat, synthetic messages, or workspace-wide scans.
- Do not remove canonical writes for messages, scope activity events, read cursors, manual unread markers, notification policy, membership, task status, or task board state. Legacy task Inbox projection columns are not current schema or canonical writes.
- Do not make runtime snapshot rows a synchronous write-through projection for read/manual/policy/task actions. The durable targeted Task v2 exact-recipient completion barrier is the only bounded exception and must not be reused by request-time serving or broad projection code.

## 15. 验证

实现必须增加 tests 或 guardrails 来保护以下 invariant：

- warm workspace refresh 执行 0 次 unread database query；
- socket heartbeat 不调用 `HasUnreadForUserWorkspaces` 或任何 unread database query；
- `listUnreadCounts` / `/channels/unread` compatibility reads 不再存在于普通 Web serving path；
- `guardrails/message/unread-compat-builders.sh` must pass, proving legacy exact
  unread aggregation helpers stay store-local and first paint、workspace
  switcher、heartbeat、route-independent bootstrap、warm refresh frontend paths
  cannot call exact unread compatibility endpoints or query options；
- non-task message write 在 durable write 成功后只在 roster/context ready 时应用 unread memory deltas；cold roster 不同步枚举 TiDB 成员，可以 queue bounded roster warm 并重放 in-memory pending events，但不能自动 replay durable tail；
- targeted Task Message feeds runtime message accounting exactly once for its Human snapshot recipients and its paired Activity does not double-account；
- targeted Task typed Activity feeds activity unread for Human snapshot recipients and Agent delivery/wake for Agent recipients without synthetic messages or removed counters；
- durable targeted Task v2 Message / Activity completion synchronously persists only frozen Human recipients' exact Task entries；missing runtime/barrier、pending accounting 或 persistence failure 都让 outbox deferred，且发生在 Agent wake / delivered 之前；
- large-scope warm roster accounting 必须按 event queue / accounting batch / max sync members 预算分批执行；超预算时 suppress/not-ready，而不是 durable-fact repair；
- non-task high-signal durable non-message activity unread 参与 workspace `hasUnread` OR 语义，低信号 durable activity 不误增 unread；
- a Task Activity becomes unread only for the committed attention snapshot; merely being a visible participant or being able to read its `scope_activity_events` row is insufficient；
- interval snapshot flush 只写 dirty entries，heartbeat/refresh/read request/ordinary explicit writes 不产生 synchronous snapshot writes；targeted Task v2 completion barrier 是唯一 bounded exact-recipient exception；
- snapshot hydrate reads only snapshot rows for V1 serving; it does not read metadata, `scope_messages`, `scope_activity_events`, removed taskattention rows, membership rows, or policy rows to repair count；
- corrupt/incompatible/unauthorized/invisible snapshots are suppressed for safety；
- after restart, snapshot-serving can return snapshot hint and is not promoted by automatic count reconciliation；
- hydrating/stale/unavailable unread 对外不显示，而不是 broad synchronous exact count aggregation；
- operator/manual repair 可以从 durable message / non-message activity / task activity / read / policy facts rebuild；普通 serving path 不触达这些 reads；
- `profile=central` 的所有 `WorkspaceResponse` management surfaces 不显示 unread，不调用 owner-local runtime、owner-hop 或 TiDB unread query；
- owner mismatch 不会从 non-owning process 服务 unread；
- future multi-server support 使用 external runtime substrate，而不是 peer process heap coordination 或 database polling。

## 16. 验收标准

目标架构满足条件：

- workspace red dots、sidebar badges、active scope badges 和 realtime reconcile 的 warm unread reads 都由 UnreadRuntime memory 服务；
- refresh 和 heartbeat 不执行 unread database reads；
- unread updates 在 durable facts 写入后先应用到 owner-local memory when enough runtime context is already warm；
- V1 只新增 scope-level runtime snapshot 表；不新增 workspace-level snapshot、workspace unread count、hot-path checkpoint 表、frontier 表或 unread projection 表；
- snapshot flush 是 interval/batch-only dirty flush；ordinary explicit writes 不同步写 snapshot；durable targeted Task v2 completion 只对 frozen Human recipients 执行 bounded exact snapshot barrier；
- after crash/restart, V1 trusts snapshot as the initial best-effort hint and accepts ordinary stale crash-window counts instead of automatic top-up/replay/backfill；已 delivered 的 targeted Task v2 outbox 已在 wake/completion 前持久化其 frozen recipients，未越过 barrier 的 row 保持 retryable；
- task scope unread 纳入 UnreadRuntime serving state 和 workspace `hasUnread`；targeted Task Message、targeted typed Activity 与 manual unread serving 由 runtime 拥有，recipients 来自 committed Watch / assignee / direct-mention snapshot，`internal/store/taskattention` 和 legacy cleanup 已移除；
- TiDB 仍然是 message、message-domain non-message activity、task activity、membership、visibility、read cursor、activity cursor、manual unread、task board/policy 和 notification policy facts 的 durable truth；
- workspace `hasUnread` 覆盖 message unread、high-signal durable non-message activity unread 和 manual unread；
- 普通 message path 不写 durable per-message/per-recipient unread row；
- same-workspace multi-owner state coordination 不在 agentteam server process memory 中实现。

## 17. 后续改进方向

后续改进方向已拆到独立 spec：[`specs/message/unread-runtime-follow-up.md`](./unread-runtime-follow-up.md)。

本文件只保留 V1 unread runtime memory model、实施计划、验证和验收标准。V1 之后的 external runtime substrate、durable unread event / outbox、generation / frontier matrix、memory eviction policy，以及 Task attention / Inbox 成本边界，都在 follow-up spec 中维护。
