# Agent Note: Session log 版本机制：单调整数、升级器链、逐事件可忽略标记

Status: implemented

[English](2026-08-10-session-log-version-mechanism.md) | 中文

## 问题

Session log 在发布后必须能升级格式，而最先发布的运行时决定了此后一切的下限：第一个发布版的读取器缺少哪种拒绝和降级行为，用户手里已经装上的副本就永远补不上。发布 issue #1901 的最低要求是老运行时读到新 Session 格式时明确报不支持，而不是读错。改动前的读取器在两个方向上都做反了：`assertVersion` 对任何版本不匹配抛出同一条不区分方向的消息；JSONL 解码器把不认识的事件类型原样放行，重建时静默跳过，恢复出一个内容残缺的会话且没有任何诊断。

## 决定

**一个单调递增的整数，不分大小版本。**某一步能不能自动升级是那一步自己的属性，由它的升级器存在与否表达，不该由两级编号方案提前承诺（设计时很少能预知下一个变更算不算"大"）。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。

**升不升版本由写入方决定，与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准：静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线：header 形状、事件信封、核心事件语义、surface 机制（`SurfaceEventType` 集合、`SurfaceOp` 变体）。拿不准就升：近似恒等的升级器几乎没有成本，漏升一次会让老读取器静默读坏。

**读取规则按方向区分。**版本相等：正常读。比读取器新：拒绝，说明方向（"由更新的 harness 写入，请升级"），并给出原始日志文件的路径，用户仍能看到文本（`SessionFormatUnsupportedError`，与 `SessionPersistenceCorruptionError` 区分，因为数据没有损坏）。比读取器旧：查看时经 n→n+1 升级器链在内存中逐级转换；只有会话真正被继续时才把转换落盘（临时文件原子替换，原文件留备份）。写不出升级器的那一步留空，这会切断该步及更早所有版本的升级路径，它们降级为只能看原文。

**逐事件的 `ignorable` 标记吸收词汇表增长，普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定，单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志，除非该事件的信封带 `ignorable: true`。默认为必需：忘写标记的后果是把一个本可恢复的会话拒绝过头（体验问题），而默认可忽略会让同样的疏忽静默恢复出残缺会话（安全事故）。架构保证了这条规则成立：模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建，危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件（`session/end-seed` 是现存例子）。

## 影响

v0（0812 发布）交付的内容：分方向的拒绝并带原始日志路径；基于生成的已知词汇清单（`KNOWN_SESSION_EVENT_TYPES`，由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成，`verify-persistence-catalog` 保证新鲜）的未知事件守卫；`ignorable` 信封字段被种子校验、两个后端（SQLite 专用列，`SCHEMA_VERSION` 升到 15）和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建；写入侧目前不写 `ignorable`（还没有生产者需要它），`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前，仓库外插件的事件在第一方读取器下无法恢复会话，预发布立场接受这一点，而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效：`appendCore` 继续拒绝已淘汰的 legacy 形状，但不对新类型做词汇检查，因为写入时拒绝会让活跃会话的持久化中途停摆，代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 形状、解码任何事件行之前，直接从原始 header 行拒绝外来版本，因此结构完全不同的未来格式仍会报告升级方向而不是"损坏"；SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。

## 曾考虑的替代方案

- **大小两级版本号**：能否转换这一位信息属于每一步的升级器，把它预先固化进编号形状会做出错误承诺。
- **未知事件默认可忽略**：把忘写标记的后果从可见的过度拒绝反转成静默损坏。
- **查看时自动迁移落盘**：打开即改写把读操作变成破坏性写操作，转换器的 bug 会在浏览时损坏日志，同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
- **插件运行时注册已知事件类型**：会让已知集依赖插件组合，同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致；仓库外插件的事件按构造就在清单之外，为它们提供注册表面推迟到真有这样的消费者时再做。
