# 控制平面：身份、持久化、不变量

这是 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, 证据)` 的纯计算。核心不变量：

```
已确认版本 >= 排队版本
  且 同一身份
  且 是规范 tier（canonical：authority-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/generation-context.js` — 记录宿主本轮生成的 `type`（`normal` / `swipe` / `regenerate` / `continue` 等），并解析本轮应绑定的父 user 楼层。
- `runtime/reroll-recall-input.js` — 基于代际上下文构造召回输入；不再用一次性 marker 猜测 reroll。

**reroll 不变量：**

> reroll 助手楼层时，若上方用户楼层未变且存在可复用的持久召回记录，则复用父 user 楼层 `message.extra.bme_recall` 中的注入块；但被 reroll 的助手楼层的**图谱回滚必须保留**（走既有 `onReroll` 路径）。

换句话说：召回注入可以复用，但图谱状态该回滚还得回滚。两者不能混为一谈——这是"reroll 乱召回"修复的核心。

设计纪律：**计算与注入解耦，信任宿主生成类型，不用输入源猜 reroll**。`GENERATION_STARTED` / `GENERATION_AFTER_COMMANDS` 传入的 `type` 是权威信号：`swipe`、`regenerate`、`continue` 属于 no-new-user 生成，优先绑定上方可见 user 楼层的持久召回；`normal` 才代表新输入，需要 fresh recall。`MESSAGE_DELETED` 在 regenerate 代际中只作为预期删除处理，不会擦掉本轮召回事务。

no-new-user 的稳定路径分两段：

1. `GENERATION_AFTER_COMMANDS` 不做召回计算，直接跳过并把工作推迟到 before-combine。
2. `GENERATE_BEFORE_COMBINE_PROMPTS` 先调用 `reapplyPersistedRecallBlock`，从父 user 楼层的 `message.extra.bme_recall` 确定性重放召回块；命中后立即返回，不进入 transaction / `runRecall`。若没有记录或记录已陈旧，再落回既有 transaction + compute 兼容路径。

旧的召回事务机制仍保留为 fresh normal 和 fallback compute 的基础设施；它不再是 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)。
