Files
ST-Bionic-Memory-Ecology/docs/architecture/control-plane.md
2026-05-31 17:20:58 +00:00

112 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 控制平面:身份、持久化、不变量
这是 ST-BME 最关键的一块。过去反复出现的 bug——"提取卡住"、"未进入聊天"、"reroll 乱召回"、"一致性审计永远说有漂移"——几乎全部源于这块的状态管理。本文档记录其结构和**必须维持的不变量**。
## 根本问题(历史背景)
早期实现里,**身份、持久化确认、加载状态、图谱可写性、向量脏标记、召回复用,全都从多个异步事件路径读写同一份模块级可变状态**`currentGraph``graphPersistenceState`、pending 标志、commit marker
没有单一事实源,也没有把"做决定"和"写入"分开。每个修复都是在这些接缝上打补丁,于是每修一个就冒出下一个。
解决方向:把控制平面抽成**纯逻辑/注入式模块**,让整类 bug 在结构上不可能发生。
## 身份解析
聊天身份是一切持久化的主键。问题从来不是"chatId 这个键选错了",而是"有好几个来源都自称是当前身份,还互相偷偷顶替"。
身份核心在 `runtime/identity-resolver.js`,把身份明确按**四类语义**区分对待,绝不互相顶替:
| 类别 | 含义 | 来源 |
| --- | --- | --- |
| **active / current identity** | 当前宿主活动聊天 | 只来自宿主上下文context integrity / hostChatId 的已知别名 / hostChatId |
| **graph-owner identity** | 图谱自带的所属身份 | 图谱 meta只用于校验/恢复 |
| **queued identity** | 排队持久化的身份 | 持久化状态,只用于校验/恢复 |
| **marker identity** | commit marker 的身份 | commit marker只用于校验/恢复 |
实现上:
- **active/current** 由独立入口 `resolveCurrentChatIdentityCore()` 解析,只认宿主上下文来源。
- **graph-owner** 由 `resolveGraphOwnerIdentityCore()` 解析。
- **queued / marker** 没有各自独立的解析入口——它们和 graph-owner 一起,在 `resolveRuntimeGraphFallbackIdentityCore()` / `resolvePersistenceChatIdCore()` 这类**恢复/兜底**聚合里被读取(含 `persistenceState.queuedPersistChatId``persistenceState.commitMarker.chatId`),并配合调用方的身份等值校验使用。
关键不在于"每类都有独立函数",而在于**只有 active/current 这条通道能产出"当前聊天",其余通道一律只进校验/恢复,不能升格为活动身份**。
**核心不变量:**
> active identity 只能来自宿主上下文。graph-owner / queued / marker 身份只能用于校验和恢复,**绝不能"偷偷"变成当前聊天**。
这正是"未进入聊天"那类 bug 的根:旧代码用一个"优先级抽奖"函数接受十几个竞争来源,结果某个非活动身份被当成了活动聊天。现在 active/current 有专门入口,恢复/兜底身份走单独的 fallback 聚合,不给"非活动身份污染活动身份"留口子。
> 身份是每次操作解析一次、显式传递的,不是从全局随用随取。
## 持久化确认状态机
持久化确认逻辑收敛在 `sync/persistence-reducer.js`,是**纯函数**:无 IO、无图谱变更、无 UI 副作用。
它把"这批记忆到底存好了没"变成关于 `(身份, 存储层 tier, 版本 revision, 证据)` 的纯计算。核心不变量:
```
已确认版本 >= 排队版本
且 同一身份
且 是规范 tiercanonicalauthority-sql / indexeddb / opfs / luker-chat-state
⟹ pendingPersist 必须为 false
```
> 实现说明:这条不变量**不是**某个单一 reducer 事件全包的。`reducePersistenceStatePatch()` 处理通用 `ACCEPTED` / `QUEUED` 事件;`buildAcceptedPersistenceStatePatch()` 在规范 tier 被接受时清 `pendingPersist`(但它本身不查排队版本/身份);"陈旧 pending 自动清除"的规划逻辑在纯函数 `planAcceptedPendingClear()``sync/legacy-persistence-repair.js`,经 reducer re-export**身份等值校验在调用方**`index.js` 接受路径)完成后才调用应用函数。所以这条不变量 = 纯规划器 + 调用方身份门禁的合成,而非单个事件转换。
**派生不变量:**
> recovery-only tier`shadow` / `metadata-full` / `runtime-recovery` 等)永远不能推进确认状态。它们只用于灾难恢复,不能被当作"数据已安全落地"的证据。
> 当 `lastAcceptedRevision >= max(batchRevision, queuedPersistRevision)` 且排队聊天与当前聊天一致时,陈旧的 `pendingPersist` 标志自动清除。
这条解决了"SQL 已确认 rev=2、但 pendingPersist 赖着不走、把提取一直卡死"的 bug。reducer 让"陈旧 pending 卡住提取"在结构上不可能发生。
历史上的语义修复Phase 2 引入不变量、Phase 5 把调用点改为显式事件)都保留在该文件头注释里。
## 图谱可写性门禁
`sync/graph-mutation-gate.js` 决定"现在能不能改图谱",避免在加载中/恢复中/未进入聊天时误写。
关键判定(注入式 impl
- `ensureGraphMutationReady` — 操作前的总门禁
- `getGraphMutationBlockReason` — 给用户的暂停原因文案
- `assertRecoveryChatStillActive` — 异步恢复过程中,校验聊天没被切走(切走则抛 abort
- `getGraphPersistenceLiveState` — 把内部状态投影成面板/调试可读形态
## 向量门禁与 reroll 边界
- `vector/vector-gate.js` — 向量准备/修复前置门禁,决定 skip / repair / blocked / sync。
- `runtime/reroll-transaction-boundary.js` — reroll 召回复用事务边界。
**reroll 不变量:**
> reroll 助手楼层时,若上方用户楼层未变且存在可复用的持久召回记录,则跳过预召回历史恢复和新向量检索;但被 reroll 的助手楼层的**图谱回滚必须保留**(走既有 `onReroll` 路径)。
换句话说:召回注入可以复用,但图谱状态该回滚还得回滚。两者不能混为一谈——这是"reroll 乱召回"修复的核心。
## 副本一致性模型
Authority 场景下有三处存储,它们**不是平级的版本副本**
| 存储 | 角色 |
| --- | --- |
| Authority SQL | **规范主源**canonical primary |
| Blob checkpoint | 备份副本backup replica |
| Trivium | 搜索副本search replica |
**不变量:**
> 只有 Authority SQL 有可靠的图谱版本。当 SQL rev > Blob/Trivium rev 时,状态是"副本待同步"**不是**"数据漂移"。SQL 领先时不建议从 checkpoint 恢复(那会用旧数据覆盖新数据)。
> checkpoint 生成时,若 SQL 是主存储层,必须以 Authority SQL 快照为源SQL 导出失败/为空时checkpoint 生成失败(`authority-sql-checkpoint-source-empty`),绝不回退到可能陈旧的运行时图谱。
> 副本同步动作checkpoint 写入、Trivium/向量同步)相互独立执行,一个失败不阻塞其余。
## 依赖注入接缝
控制平面模块通过一个 `runtime` 对象拿到所有依赖,由 `index.js``create*Runtime()` builder 提供。这有个隐患:模块"期望"的 `runtime.X` 必须全部被 builder 提供,否则运行时(尤其 fallback 路径)才炸。
`tests/runtime-deps-completeness.mjs` 守住这条线。详见 [`../contributing/conventions.md`](../contributing/conventions.md) 和 [`../contributing/testing.md`](../contributing/testing.md)。