Explorar o código

feat(session): add format migration decoder pipeline

_Kerman hai 1 mes
pai
achega
cc9ab200c7
Modificáronse 27 ficheiros con 3409 adicións e 700 borrados
  1. 2 2
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
  5. 13 2
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
  6. 13 2
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
  7. 2 2
      docs/config-catalog.i18n.yaml
  8. 2 2
      docs/config-catalog.md
  9. 2 2
      docs/config-catalog.zh.md
  10. 16 6
      packages/host/apiproxy/tests/api-proxy-cold.spec.ts
  11. 33 38
      packages/session/session-persistence-jsonl/src/format.ts
  12. 267 51
      packages/session/session-persistence-jsonl/src/index.ts
  13. 14 0
      packages/session/session-persistence-jsonl/src/win32.ts
  14. 338 14
      packages/session/session-persistence-jsonl/tests/jsonl.spec.ts
  15. 36 0
      packages/session/session-persistence-jsonl/tests/win32.spec.ts
  16. 50 2
      packages/session/session-persistence-jsonl/tests/zstd.spec.ts
  17. 188 20
      packages/session/session-persistence-sqlite/src/index.ts
  18. 21 1
      packages/session/session-persistence-sqlite/src/schema.ts
  19. 212 3
      packages/session/session-persistence-sqlite/tests/sqlite.spec.ts
  20. 157 521
      packages/session/session-persistence/src/coordinator.ts
  21. 480 0
      packages/session/session-persistence/src/format-decoder.ts
  22. 6 0
      packages/session/session-persistence/src/format-migrations/index.ts
  23. 293 0
      packages/session/session-persistence/src/format-v0-compat.ts
  24. 15 6
      packages/session/session-persistence/src/index.ts
  25. 9 0
      packages/session/session-persistence/src/revision.ts
  26. 889 0
      packages/session/session-persistence/tests/format-decoder.spec.ts
  27. 347 22
      packages/session/session-persistence/tests/persistence.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md
-2026-06-14-session-persistence.md: 62228bd2f5b25b13880a563818d08f3a2d52d956
-2026-06-14-session-persistence.zh.md: b10ceebd95d4d05ae3f7ea620183ad8fffd074ee
+2026-06-14-session-persistence.md: cef11271f26c304ad484d7851801bc69d0c1dfda
+2026-06-14-session-persistence.zh.md: ecc85439bb47d6c1fb3280f9c9716d29f3684aea

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-14-session-persistence.md

@@ -29,7 +29,7 @@ Key durable, contested choices:
 
 Each key choice above records its rejected alternative where the choice is stated: a **chunk-filtered canonical log** (Codex's `policy.rs` shape) — breaks the contiguous-seq contract; **truncating a crashed turn** — silently destroys a long autonomous run's real work; an **in-log `session/meta` event as line 0** — metadata is not replayable state; **finite fractional `createdAt` values** — have no producer and diverge from integer Unix-millisecond storage and query columns; **adopting a non-pristine unversioned SQLite file** — can overwrite unrelated objects or identity; **hard-injecting `sessionPersistence` into the loop** — would pend non-persistent demos forever.
 
-Format versioning: the header carries a `version`; cold reads reject any non-current version. The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise, while the coordinator may own an explicit narrow import upgrade when persisted user data requires it ([pre-identity message recovery](../bug-fix/2026-07-28-load-pre-identity-session-messages.md)). Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there.
+Format versioning: the header carries a `version`; cold reads accept the current version or a complete static adjacent-version decoder path and reject future versions or missing steps. The format decoder owns historical header and event conversion, while the Coordinator owns operation-specific recovery after decoding ([Session log versioning](2026-08-10-session-log-version-mechanism.md)). The pre-release session format stays pinned at `SESSION_FORMAT_VERSION = 0` and carries no broad compatibility promise. Append-only + flush is robust to partial trailing writes (tolerated during cold preparation) but not to fsync-less power loss mid-line; a DB/WAL backend is the stronger option there.
 
 ## Consequences
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md

@@ -29,7 +29,7 @@ Status: implemented
 
 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。
 
-格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。
+格式版本控制:header 携带一个 `version`;冷读取接受当前版本或完整的静态相邻版本 decoder 路径,并拒绝未来版本或缺失步骤。Format decoder 负责历史 header 和 event 转换,Coordinator 只在解码后负责各操作自己的 recovery([Session log 版本机制](2026-08-10-session-log-version-mechanism.md))。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。
 
 ## 后果
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
-2026-08-10-session-log-version-mechanism.md: 25eb1230a254219c827b1d2750dba367b113f9f7
-2026-08-10-session-log-version-mechanism.zh.md: ac1088527ba14a8018c5a7fb3c77c9232bd3b3a9
+2026-08-10-session-log-version-mechanism.md: eb9fcef3fa677ee3912c2cbd611601b6b6f83db1
+2026-08-10-session-log-version-mechanism.zh.md: aa257482869a2b18bd0d3fcb2bc883a8cf506dcf

+ 13 - 2
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md

@@ -14,13 +14,21 @@ Session logs must be upgradable after release, and the runtime that ships first
 
 **The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.
 
-**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing.
+**Read rules by direction.** Equal version: decode normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: require a complete chain of static n→n+1 `SessionFormatStep`s; a missing step refuses the read and names the gap. The registry is part of the build rather than Cordis composition, so one build has the same durable read capability under every plugin set.
+
+**Format migration is the decoder, not a Coordinator repair branch.** Backends expose parsed durable data as `unknown` through a repeatable `StoredSessionSource`: one raw header, one exact revision, and `readEvents()` factories that create independently consumable `AsyncIterable` streams bound to that revision. Each step implements `migrateHeader()` and lazy `migrateEvents()` transforms, must advance exactly one version, and may not change the session id or cwd. Any version conversion reads the complete event stream and applies the requested suffix only after all steps; an equal-version read retains backend suffix seek. The decoder validates each output header version, then applies current `SessionHeader` and `SessionEvent` validation only after the complete chain.
+
+**A future format bump adds one format-owned step.** The change adds `format-migrations/vN-to-vN+1.ts`, exports that step from the static `SESSION_FORMAT_STEPS` array, and increments `SESSION_FORMAT_VERSION`. The step owns every old header and event variant it accepts, cross-event state inside its iterator transform, and explicit failure for malformed input. Backends and the Coordinator do not gain version-specific branches. Historical variants that never changed the version remain isolated in the format-v0 compatibility decoder and are not a template for later version steps.
+
+**Recovery and writeback consume current-format data.** `inspect()` and `readFrom()` decode only in memory. Cold `prepare()`/`load()` first decode the whole source, add the current recovery closers, and replace the exact old revision with that complete balanced current-format stream. Live HMR adoption uses the same replacement primitive after seed verification but does not synthesize closers for a turn still owned by the live Session. A successful replacement or revision conflict discards the prepared object and reopens the stored source before continuing.
+
+**Replacement is an internal backend compare-and-swap.** `replaceStored(expectedRevision, meta, events)` accepts a streaming current-format log and checks storage identity plus the source revision inside the commit exclusion. JSONL writes and fsyncs a sibling temporary artifact, rechecks the revision while holding its cross-process lock, atomically replaces the path (using the Windows write-through replacement primitive there), and syncs the parent directory on POSIX. SQLite stages the event iterator, then rechecks and replaces the header and event rows in one transaction. A failed commit leaves one complete old or new log; retaining a permanent pre-upgrade copy is a separate recovery policy, not part of the format migration API.
 
 **A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
 
 ## Consequences
 
-What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
+Format v0 carries direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema; and the static streaming migration decoder with an empty adjacent-version registry. `SESSION_FORMAT_VERSION` remains 0 until a real v0→v1 step lands. The decoder and backend replacement APIs therefore have direct tests without manufacturing a format bump. Writers do not yet set `ignorable` because no producer needs it. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers; the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header fields or decoding any event record, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
 
 ## Alternatives considered
 
@@ -28,3 +36,6 @@ What shipped in v0 (release 0812): direction-aware refusal with the raw-log path
 - **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
 - **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked.
 - **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists.
+- **Materializing migrations as header and event arrays** — makes the framework proportional to complete log size in memory even when each transformation is record-local. Repeatable revision-bound readers plus iterator transforms preserve retry semantics without imposing that allocation.
+- **Version-specific conversion in `PersistenceCoordinator`** — mixes format decoding with operation-specific crash recovery and duplicates behavior across inspect, suffix read, cold continuation, and live adoption. The shared decoder produces only current-format data; each consumer retains its own recovery intent.
+- **A mandatory permanent backup for every upgrade** — is not needed for atomicity and cannot promise the same physical representation across JSONL and SQLite. Backends may add recovery copies as a separate product policy without changing migrations.

+ 13 - 2
.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md

@@ -14,13 +14,21 @@ Session log 在发布后必须能升级格式,而最先发布的运行时决
 
 **升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
 
-**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。
+**读取规则按方向区分。**版本相等:正常解码。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:要求静态 n→n+1 `SessionFormatStep` 组成完整链路,缺失任何一步都会拒绝并指出断点。注册表属于 build 而不是 Cordis composition,因此同一个 build 在任何插件组合下都具有相同的持久化读取能力。
+
+**格式迁移就是 decoder,不是 Coordinator 的修复分支。**后端通过可重复读取的 `StoredSessionSource` 把解析后的持久化数据作为 `unknown` 暴露:一个原始 header、一个精确 revision,以及每次产生独立 `AsyncIterable` 且绑定该 revision 的 `readEvents()` factory。每一步实现 `migrateHeader()` 和惰性的 `migrateEvents()` 转换,只能前进一个版本,也不能改变 Session id 或 cwd。只要发生版本转换,就读取完整事件流,并在所有步骤完成后才应用请求的 suffix;版本相等时仍保留 backend suffix seek。Decoder 验证每一步输出的 header version,完整链路结束后才执行当前 `SessionHeader` 和 `SessionEvent` 校验。
+
+**以后每次 format bump 只增加一个格式步骤。**改动新增 `format-migrations/vN-to-vN+1.ts`,把该步骤导出到静态 `SESSION_FORMAT_STEPS` 数组,并递增 `SESSION_FORMAT_VERSION`。这一步自己负责它接受的所有旧 header 和 event 变体、iterator 转换中的跨事件状态,以及对畸形输入的明确失败。Backend 和 Coordinator 不增加版本特判。没有改变版本号的历史变体继续隔离在 format-v0 compatibility decoder 中,不作为后续版本步骤的模板。
+
+**Recovery 和写回只消费当前格式数据。**`inspect()` 和 `readFrom()` 只在内存中解码。Cold `prepare()`/`load()` 先解码完整 source,补充当前 recovery closers,再用完整、平衡的当前格式 stream 替换精确的旧 revision。Live HMR adoption 在 seed 校验后使用同一个 replacement primitive,但不会为仍由 live Session 掌握的 turn 合成 closer。替换成功或 revision 冲突后都会丢弃 prepared object,重新打开持久化 source 后再继续。
+
+**Replacement 是 backend 内部的 compare-and-swap。**`replaceStored(expectedRevision, meta, events)` 接受流式当前格式日志,并在提交排他区内检查存储身份和 source revision。JSONL 写入并 fsync 同目录临时 artifact,在持有跨进程锁时复核 revision,原子替换路径(Windows 使用 write-through replacement primitive),并在 POSIX 上同步父目录。SQLite 先暂存 event iterator,再在一个事务中复核并替换 header 与 event rows。提交失败后只会留下完整旧日志或完整新日志;永久保留升级前副本是独立的恢复策略,不属于 format migration API。
 
 **逐事件的 `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 把关整个文件的结构。
+Format v0 包含:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 15)和 BFF 线上 schema 接受;以及使用空相邻版本注册表的静态流式 migration decoder。`SESSION_FORMAT_VERSION` 保持 0,直到真实 v0→v1 步骤合入。Decoder 和 backend replacement API 因此可以直接测试,不需要制造一次 format bump。写入侧目前不写 `ignorable`,因为还没有生产者需要它。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话;拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 字段、解码任何 event record 之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
 
 ## 曾考虑的替代方案
 
@@ -28,3 +36,6 @@ v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径
 - **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
 - **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
 - **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。
+- **把 migration 物化为 header 和 event 数组**:即使每步转换只依赖单条 record,也会让框架内存占用与完整日志大小成正比。可重复、绑定 revision 的 reader 加 iterator 转换保留重试语义,又不强制这笔分配。
+- **在 `PersistenceCoordinator` 内写版本转换**:会把格式解码和各操作不同的 crash recovery 混在一起,并在 inspect、suffix read、cold continuation 和 live adoption 间复制行为。共享 decoder 只产出当前格式数据,各 consumer 保留自己的 recovery intent。
+- **每次升级都强制永久备份**:原子性不依赖永久副本,而且 JSONL 与 SQLite 无法承诺相同的物理表示。Backend 可以把恢复副本作为独立产品策略加入,不需要修改 migration。

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 3a471ea06e911d3d29ebef4ce80353b15a21cb43
-config-catalog.zh.md: f1b53d4a2b3c15abb3176c7af5de9c577f74825f
+config-catalog.md: 8f3ed736b565b2251bc8418bd940f222f323f0b6
+config-catalog.zh.md: f494277de8f820abe318a260dfbf41dc54e0c947

+ 2 - 2
docs/config-catalog.md

@@ -1590,7 +1590,7 @@ export interface Config {
 export type JsonlCompression = 'zstd' | 'none'
 ```
 
-Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts)
+Source: [`packages/session/session-persistence-jsonl/src/index.ts:66`](../packages/session/session-persistence-jsonl/src/index.ts)
 
 <a id="deepseek-aidsh-session-persistence-sqlite"></a>
 
@@ -1635,7 +1635,7 @@ export interface Config {
 export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 ```
 
-Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts)
+Source: [`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts)
 
 <a id="deepseek-aidsh-session-projection-cache"></a>
 

+ 2 - 2
docs/config-catalog.zh.md

@@ -1592,7 +1592,7 @@ export interface Config {
 export type JsonlCompression = 'zstd' | 'none'
 ```
 
-来源:[`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts)
+来源:[`packages/session/session-persistence-jsonl/src/index.ts:66`](../packages/session/session-persistence-jsonl/src/index.ts)
 
 <a id="deepseek-aidsh-session-persistence-sqlite"></a>
 
@@ -1637,7 +1637,7 @@ export interface Config {
 export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 ```
 
-来源:[`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts)
+来源:[`packages/session/session-persistence-sqlite/src/index.ts:93`](../packages/session/session-persistence-sqlite/src/index.ts)
 
 <a id="deepseek-aidsh-session-projection-cache"></a>
 

+ 16 - 6
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -21,7 +21,7 @@ import {
   PersistenceCoordinator,
   SessionPersistenceRevision,
   type PersistenceBackend,
-  type StoredPrefix,
+  type StoredSessionSource,
 } from '@deepseek-ai/dsh-session-persistence'
 import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
 import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -271,19 +271,29 @@ describe('cold history recovery view', () => {
     await ctx.plugin(UserQuestionService)
     const sessionId = sid('session-interrupted')
     const meta = header(sessionId, 1000)
-    const stored: StoredPrefix<never> = {
+    const revision = SessionPersistenceRevision('history-recovery-test:1')
+    const stored: StoredSessionSource<never> = {
       meta,
-      events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }],
-      revision: SessionPersistenceRevision('history-recovery-test:1'),
+      revision,
+      readEvents: ({ fromSeq = 0 } = {}) => ({
+        events: (async function* () {
+          const events: SessionEvent[] = [
+            { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+          ]
+          for (const event of events.slice(fromSeq)) yield structuredClone(event)
+        })(),
+        completed: Promise.resolve({}),
+      }),
     }
     const backend: PersistenceBackend<never> = {
       name: 'history-recovery-test',
-      loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined),
+      openStored: id => Promise.resolve(id === sessionId ? stored : undefined),
       readStoredRevision: id => Promise.resolve(
-        id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined,
+        id === sessionId ? revision : undefined,
       ),
       appendBatch: () => Promise.resolve(),
       commitRepair: () => Promise.resolve(),
+      replaceStored: () => Promise.resolve(),
       list: () => Promise.resolve([structuredClone(meta)]),
     }
     const coordinator = new PersistenceCoordinator(ctx, backend)

+ 33 - 38
packages/session/session-persistence-jsonl/src/format.ts

@@ -11,7 +11,6 @@
 import { join } from 'node:path'
 import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session'
-import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence'
 
 /** Physical encoding selected for JSONL session artifacts. */
 export type JsonlCompression = 'zstd' | 'none'
@@ -224,29 +223,26 @@ export function eventLines(events: readonly SessionEvent[], packChunks: boolean)
 }
 
 interface SessionLogScan {
-  meta: SessionHeader
-  events: SessionEvent[]
+  meta: unknown
+  events: unknown[]
   committedBytes: number
 }
 
-/** Parse one complete header record supplied independently from event rows. */
-/**
- * Refuse a header carrying a format version this build does not read BEFORE
- * validating the current header shape or decoding any event row: a future
- * format need not satisfy today's structural checks at all, and its user must
- * see "upgrade the harness", never "corrupt session log".
- * @param parsed - the JSON-parsed first line of a session artifact.
- */
-function refuseForeignFormatVersion(parsed: unknown): void {
-  if (typeof parsed !== 'object' || parsed === null) return
-  const { version, id } = parsed as { version?: unknown; id?: unknown }
-  if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return
-  throw new SessionFormatUnsupportedError(
-    sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version),
-  )
+/** Parse the version-independent identity fields from one physical header row. */
+function parseStoredHeader(value: unknown): Record<string, unknown> | undefined {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
+  const record = value as Record<string, unknown>
+  if (record['type'] !== 'session'
+    || !Number.isSafeInteger(record['version'])) return undefined
+  if (record['version'] === SESSION_FORMAT_VERSION) {
+    return isHeaderLine(record) ? fromHeaderLine(record) as unknown as Record<string, unknown> : undefined
+  }
+  const { type: _type, ...meta } = record
+  return meta
 }
 
-function parseHeaderRecord(record: Buffer): SessionHeader {
+/** Parse one complete header record supplied independently from event rows. */
+function parseHeaderRecord(record: Buffer): unknown {
   if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) {
     throw new Error('empty or header-less session log')
   }
@@ -256,11 +252,11 @@ function parseHeaderRecord(record: Buffer): SessionHeader {
   } catch {
     throw new Error('corrupt session log: header line is not valid JSON')
   }
-  refuseForeignFormatVersion(parsed)
-  if (!isHeaderLine(parsed)) {
+  const meta = parseStoredHeader(parsed)
+  if (meta === undefined) {
     throw new Error('corrupt session log: first line is not a session header')
   }
-  return fromHeaderLine(parsed)
+  return meta
 }
 
 /**
@@ -270,8 +266,8 @@ function parseHeaderRecord(record: Buffer): SessionHeader {
  * copied because a decoder may reuse its output buffer after `write()` returns.
  */
 export class SessionLogScanner {
-  private readonly meta: SessionHeader
-  private readonly events: SessionEvent[] = []
+  private readonly meta: unknown
+  private readonly events: unknown[] = []
   private fragments: Buffer[] = []
   private fragmentBytes = 0
   private inputBytes: number
@@ -346,7 +342,7 @@ export class SessionLogScanner {
   /** Decode one complete event row and update the contiguous prefix. */
   private consumeEventLine(line: Buffer, endByte: number): void {
     this.eventLine += 1
-    let decoded: SessionEvent[]
+    let decoded: unknown[]
     try {
       decoded = decodeStorageRecord(JSON.parse(line.toString('utf8')))
     } catch {
@@ -355,20 +351,21 @@ export class SessionLogScanner {
     }
 
     if (this.issue !== undefined) {
-      if (decoded.some(event => event.type === 'turn/end')) throw this.issue
+      if (decoded.some(event => (event as { type?: unknown }).type === 'turn/end')) throw this.issue
       return
     }
 
     const rowStart = this.events.length
     for (const event of decoded) {
-      if (event.seq !== this.events.length) {
+      const seq = (event as { seq?: unknown }).seq
+      if (seq !== this.events.length) {
         const expected = this.events.length
         this.events.length = rowStart
         this.issue = new Error(
           `corrupt session log: seq gap in committed region at line ${this.eventLine} `
-          + `(expected ${expected}, got ${event.seq})`,
+          + `(expected ${expected}, got ${String(seq)})`,
         )
-        if (decoded.some(candidate => candidate.type === 'turn/end')) throw this.issue
+        if (decoded.some(candidate => (candidate as { type?: unknown }).type === 'turn/end')) throw this.issue
         return
       }
       this.events.push(event)
@@ -394,20 +391,18 @@ export function scanLog(buffer: Buffer): SessionLogScan {
 }
 
 /**
- * Parse just the header line of a log into a {@link SessionHeader}, or
- * `undefined` if it is missing/not a header. Used by `list()` to read session
- * metadata WITHOUT parsing the whole log: a session picker scales with the
- * number of sessions, not the total size of every conversation.
- * @param firstLine - the first line of a log file (without its trailing newline).
- * @returns the parsed header, or `undefined` when the line is not a well-formed session header.
+ * Parse only the version-independent identity envelope from one physical
+ * header line. Format migration and current validation run in the persistence
+ * decoder.
+ * @param firstLine - first JSONL record without its newline.
+ * @returns normalized logical header JSON, or `undefined` for invalid framing.
  */
-export function parseHeaderMeta(firstLine: string): SessionHeader | undefined {
+export function parseStoredHeaderMeta(firstLine: string): unknown {
   let parsed: unknown
   try {
     parsed = JSON.parse(firstLine)
   } catch {
     return undefined
   }
-  if (!isHeaderLine(parsed)) return undefined
-  return fromHeaderLine(parsed)
+  return parseStoredHeader(parsed)
 }

+ 267 - 51
packages/session/session-persistence-jsonl/src/index.ts

@@ -9,28 +9,30 @@
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { readdirSync } from 'node:fs'
-import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises'
+import { open, mkdir, readFile, readdir, realpath, link, rename, rm, stat, truncate, writeFile } from 'node:fs/promises'
 import { dirname, join, resolve } from 'node:path'
 import { performance } from 'node:perf_hooks'
 import { scheduler } from 'node:timers/promises'
 import { randomBytes } from 'node:crypto'
 import {
   DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
-  SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError,
+  decodeStoredSessionHeader, SessionPersistence, SessionPersistenceRevision,
+  SessionPersistenceRevisionConflictError, PersistenceCoordinator,
   type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
   type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact,
-  type StoredPrefix,
+  type StoredEventRead, type StoredSessionSource,
 } from '@deepseek-ai/dsh-session-persistence'
-import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
 import {
-  encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir,
+  encodeSegment, eventLines, logPath, logSuffix, parseStoredHeaderMeta, projectDir, scanLog, sessionDir,
   SessionLogScanner, toHeaderLine,
   type JsonlCompression,
 } from './format.ts'
 import {
   compressZstdFrame, createZstdFrameDecoder, decompressZstdFrame, decompressZstdPrefix, scanZstdFrames,
 } from './zstd.ts'
-import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
+import { ensureDurableDirectoryWin32, publishNewFileWin32, replaceFileWin32 } from './win32.ts'
 
 export type { JsonlCompression } from './format.ts'
 
@@ -42,6 +44,10 @@ const DEFAULT_COMPRESSION: JsonlCompression = 'zstd'
  * remains an indivisible synchronous decode.
  */
 const ZSTD_DECODE_YIELD_INTERVAL_MS = 500
+const LOG_LOCK_RETRY_INITIAL_MS = 20
+const LOG_LOCK_RETRY_MAX_MS = 200
+const LOG_LOCK_TIMEOUT_MS = 2_000
+const REPLACEMENT_BATCH_SIZE = 128
 
 /** Assert that the independently decodable first frame contains only the header record. */
 function assertZstdHeaderFrame(plaintext: Buffer): void {
@@ -88,6 +94,32 @@ interface JsonlTornMarker {
   recoveredEvents: SessionEvent[]
 }
 
+interface JsonlStoredPrefix {
+  readonly meta: unknown
+  readonly events: unknown[]
+  readonly revision: PersistenceRevision
+  readonly tornMarker?: JsonlTornMarker
+}
+
+interface JsonlStoredHeader {
+  readonly meta: unknown
+  readonly revision: PersistenceRevision
+}
+
+function deferred<T>(): {
+  readonly promise: Promise<T>
+  resolve(value: T): void
+  reject(reason: unknown): void
+} {
+  let resolve!: (value: T) => void
+  let reject!: (reason: unknown) => void
+  const promise = new Promise<T>((accept, decline) => {
+    resolve = accept
+    reject = decline
+  })
+  return { promise, resolve, reject }
+}
+
 interface FileRevisionIdentity {
   readonly dev: bigint
   readonly ino: bigint
@@ -112,6 +144,10 @@ function isENOENT(error: unknown): boolean {
   return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
 }
 
+function isEEXIST(error: unknown): boolean {
+  return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
+}
+
 /**
  * The JSONL persistence backend. Load as a plugin; it registers as
  * `ctx.sessionPersistence` and (via the coordinator) installs the write-path
@@ -193,8 +229,8 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
     return this.coordinator.inspect(id, signal)
   }
 
-  // JSONL is sequential media: no loadStoredFrom hook, so the coordinator
-  // parses the stored prefix (both encodings) and skips forward to fromSeq.
+  // JSONL is sequential media: its source reader parses the stored prefix and
+  // filters only after physical framing and sequence checks.
   readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
     return this.coordinator.readFrom(id, fromSeq, signal)
   }
@@ -205,14 +241,43 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
   /* jscpd:ignore-end */
   // --- PersistenceBackend hooks (the file-bytes storage primitives) ---
 
-  /** Read a stored prefix by id across all project directories when cwd is unknown. */
-  async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<JsonlTornMarker> | undefined> {
+  /** Open repeatable reads over one revision resolved across project directories. */
+  async openStored(id: SessionId, signal?: AbortSignal): Promise<StoredSessionSource<JsonlTornMarker> | undefined> {
     signal?.throwIfAborted()
     await this.ensureRootEncoding()
     signal?.throwIfAborted()
     const path = await this.findLog(id, signal)
     if (path === undefined) return undefined
-    return this.readPrefix(path, id, signal)
+    const { meta, revision } = await this.readStoredHeader(path, id, signal)
+    return {
+      meta,
+      revision,
+      location: { kind: 'jsonl', path },
+      readEvents: (options = {}): StoredEventRead<JsonlTornMarker> => {
+        const completed = deferred<{ tornMarker?: JsonlTornMarker }>()
+        const events = (async function* (backend: JsonlSessionPersistence): AsyncIterable<unknown> {
+          try {
+            const prefix = await backend.readPrefix(path, id, signal)
+            if (prefix.revision !== revision) {
+              throw new SessionPersistenceRevisionConflictError(
+                `session "${id}" changed while reading revision ${revision}`,
+              )
+            }
+            const fromSeq = options.fromSeq ?? 0
+            for (const event of prefix.events) {
+              signal?.throwIfAborted()
+              const seq = (event as { seq?: unknown }).seq
+              if (typeof seq !== 'number' || seq >= fromSeq) yield event
+            }
+            completed.resolve(prefix.tornMarker === undefined ? {} : { tornMarker: prefix.tornMarker })
+          } catch (error: unknown) {
+            completed.reject(error)
+            throw error
+          }
+        })(this)
+        return { events, completed: completed.promise }
+      },
+    }
   }
 
   /**
@@ -272,10 +337,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
     } else {
       content = buffer.toString('utf8')
     }
-    const meta = parseHeaderMeta(content.split('\n', 1)[0] as string)
-    if (meta === undefined || meta.id !== id) {
+    const rawMeta = parseStoredHeaderMeta(content.split('\n', 1)[0] as string)
+    if (rawMeta === undefined) {
       throw new Error(`corrupt session log: invalid header line in "${path}"`)
     }
+    const meta = decodeStoredSessionHeader(rawMeta, id, { kind: 'jsonl', path })
     // The logical artifact name is `session.jsonl` regardless of the physical
     // encoding suffix (`.jsonl.zstd` marks compression only).
     return { meta, filename: 'session.jsonl', content }
@@ -303,6 +369,31 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
     }
   }
 
+  /** Read one version-independent header at a stable file revision. */
+  private async readStoredHeader(
+    path: string,
+    _expectedId?: SessionId,
+    signal?: AbortSignal,
+  ): Promise<JsonlStoredHeader> {
+    for (;;) {
+      signal?.throwIfAborted()
+      const before = fileRevision(await stat(path, { bigint: true }))
+      const firstLine = this.compression === 'zstd'
+        ? await this.readFirstZstdLine(path, signal)
+        : await this.readFirstLine(path, signal)
+      const after = fileRevision(await stat(path, { bigint: true }))
+      if (before !== after) continue
+      if (firstLine === undefined) {
+        throw new Error(this.compression === 'zstd'
+          ? `empty or header-less Zstandard session log at "${path}"`
+          : `empty or header-less session log at "${path}"`)
+      }
+      const meta = parseStoredHeaderMeta(firstLine)
+      if (meta === undefined) throw new Error(`corrupt session log: first line is not a session header in "${path}"`)
+      return { meta, revision: after }
+    }
+  }
+
   /**
    * Read a stored prefix and convert torn-tail state to the opaque marker the
    * coordinator can round-trip without knowing the physical encoding.
@@ -311,32 +402,22 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
     path: string,
     expectedId?: SessionId,
     signal?: AbortSignal,
-  ): Promise<StoredPrefix<JsonlTornMarker>> {
+  ): Promise<JsonlStoredPrefix> {
     const { buffer, revision } = await this.readStableFile(path, signal)
-    let prefix: Omit<StoredPrefix<JsonlTornMarker>, 'revision'>
-    try {
-      if (this.compression === 'zstd') {
-        prefix = await this.readZstdPrefix(buffer, signal)
-      } else {
-        signal?.throwIfAborted()
-        const { meta, events, committedBytes } = scanLog(buffer)
-        signal?.throwIfAborted()
-        prefix = {
-          meta,
-          events,
-          ...committedBytes < buffer.byteLength
-            ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } }
-            : {},
-        }
-      }
-    } catch (error: unknown) {
-      // A parse-time format refusal predates any SessionHeader, so the
-      // coordinator's locate-based enrichment cannot run; attach the artifact
-      // this read actually refused.
-      if (error instanceof SessionFormatUnsupportedError && error.location === undefined) {
-        throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path })
+    let prefix: Omit<JsonlStoredPrefix, 'revision'>
+    if (this.compression === 'zstd') {
+      prefix = await this.readZstdPrefix(buffer, signal)
+    } else {
+      signal?.throwIfAborted()
+      const { meta, events, committedBytes } = scanLog(buffer)
+      signal?.throwIfAborted()
+      prefix = {
+        meta,
+        events,
+        ...committedBytes < buffer.byteLength
+          ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } }
+          : {},
       }
-      throw error
     }
     signal?.throwIfAborted()
     await this.assertStoredIdentity(path, prefix.meta, expectedId, signal)
@@ -348,7 +429,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
   private async readZstdPrefix(
     buffer: Buffer,
     signal?: AbortSignal,
-  ): Promise<Omit<StoredPrefix<JsonlTornMarker>, 'revision'>> {
+  ): Promise<Omit<JsonlStoredPrefix, 'revision'>> {
     signal?.throwIfAborted()
     const { frames, tornStart } = scanZstdFrames(buffer)
     signal?.throwIfAborted()
@@ -406,7 +487,7 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
         events: recoveredPrefix.events,
         tornMarker: {
           truncateTo: tornStart,
-          recoveredEvents: recoveredPrefix.events.slice(complete.eventCount),
+          recoveredEvents: recoveredPrefix.events.slice(complete.eventCount) as SessionEvent[],
         },
       }
     } catch (error) {
@@ -422,7 +503,8 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
   async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise<void> {
     await this.ensureRootEncoding()
     if (isMaterialized) {
-      await this.appendLines(meta, events)
+      const path = logPath(this.root, meta.cwd, meta.id, this.compression)
+      await this.withLogLock(path, () => this.appendLines(meta, events))
     } else {
       await this.materialize(meta, events)
     }
@@ -438,9 +520,69 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
     tornMarker: JsonlTornMarker | undefined,
     closers: readonly SessionEvent[],
   ): Promise<void> {
-    if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo)
-    const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers]
-    if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents)
+    const path = logPath(this.root, meta.cwd, meta.id, this.compression)
+    await this.withLogLock(path, async () => {
+      if (tornMarker !== undefined) await this.repair(meta, tornMarker.truncateTo)
+      const repairedEvents = [...(tornMarker?.recoveredEvents ?? []), ...closers]
+      if (repairedEvents.length > 0) await this.appendLines(meta, repairedEvents)
+    })
+  }
+
+  /** Replace one exact source revision through a synced sibling and atomic namespace update. */
+  async replaceStored(
+    expectedRevision: PersistenceRevision,
+    meta: SessionHeader,
+    events: AsyncIterable<SessionEvent>,
+  ): Promise<void> {
+    await this.ensureRootEncoding()
+    const path = await this.findLog(meta.id)
+    if (path === undefined) {
+      throw new SessionPersistenceRevisionConflictError(
+        `session "${meta.id}" no longer has revision ${expectedRevision}`,
+      )
+    }
+    await this.withLogLock(path, async () => {
+      let current: JsonlStoredHeader
+      try {
+        current = await this.readStoredHeader(path, meta.id)
+      } catch (error: unknown) {
+        if (isENOENT(error)) {
+          throw new SessionPersistenceRevisionConflictError(
+            `session "${meta.id}" no longer has revision ${expectedRevision}`,
+          )
+        }
+        throw error
+      }
+      if (current.revision !== expectedRevision) {
+        throw new SessionPersistenceRevisionConflictError(
+          `session "${meta.id}" changed before replacement of revision ${expectedRevision}`,
+        )
+      }
+      const currentIdentity = this.storedIdentity(current.meta, path)
+      if (meta.cwd !== currentIdentity.cwd) {
+        throw new Error(`replacement for session "${meta.id}" changes its stored identity`)
+      }
+
+      const tmp = `${path}.${randomBytes(6).toString('hex')}.upgrade.tmp`
+      try {
+        await this.writeReplacement(tmp, meta, events)
+        const beforeCommit = fileRevision(await stat(path, { bigint: true }))
+        if (beforeCommit !== expectedRevision) {
+          throw new SessionPersistenceRevisionConflictError(
+            `session "${meta.id}" changed before replacement of revision ${expectedRevision}`,
+          )
+        }
+        /* v8 ignore next -- Windows uses its write-through replacement primitive. */
+        if (process.platform === 'win32') {
+          await replaceFileWin32(tmp, path)
+        } else {
+          await rename(tmp, path)
+          await this.syncDirPosix(dirname(path))
+        }
+      } finally {
+        await rm(tmp, { force: true })
+      }
+    })
   }
 
   /** List valid unique stored sessions' metadata (header line only — no full-log parse). */
@@ -493,9 +635,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
           : await this.readFirstLine(path, signal)
         signal?.throwIfAborted()
         if (first === undefined) continue // empty/half-written file
-        const meta = parseHeaderMeta(first)
-        if (meta === undefined) continue // not a session header
-        await this.assertStoredIdentity(path, meta, undefined, signal)
+        const rawMeta = parseStoredHeaderMeta(first)
+        if (rawMeta === undefined) continue // not a session header
+        const identity = this.storedIdentity(rawMeta, path)
+        const meta = decodeStoredSessionHeader(rawMeta, identity.id, { kind: 'jsonl', path })
+        await this.assertStoredIdentity(path, rawMeta, undefined, signal)
         signal?.throwIfAborted()
         if (ids.has(meta.id)) {
           throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`)
@@ -615,6 +759,59 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
     return tmp
   }
 
+  /** Stream one complete current-format replacement into a synced temp file. */
+  private async writeReplacement(
+    path: string,
+    meta: SessionHeader,
+    events: AsyncIterable<SessionEvent>,
+  ): Promise<void> {
+    const handle = await open(path, 'wx', 0o600)
+    try {
+      const header = JSON.stringify(toHeaderLine(meta)) + '\n'
+      await handle.writeFile(this.compression === 'zstd' ? await compressZstdFrame(header) : header)
+      let batch: SessionEvent[] = []
+      const writeBatch = async (): Promise<void> => {
+        if (batch.length === 0) return
+        const body = eventLines(batch, this.packChunks) + '\n'
+        await handle.writeFile(this.compression === 'zstd' ? await compressZstdFrame(body) : body)
+        batch = []
+      }
+      for await (const event of events) {
+        batch.push(event)
+        if (batch.length === REPLACEMENT_BATCH_SIZE) await writeBatch()
+      }
+      await writeBatch()
+      await handle.sync()
+    } finally {
+      await handle.close()
+    }
+  }
+
+  /** Serialize cooperating cross-process mutations of one materialized log. */
+  private async withLogLock<T>(path: string, operation: () => Promise<T>): Promise<T> {
+    const lockPath = `${path}.lock`
+    const deadline = Date.now() + LOG_LOCK_TIMEOUT_MS
+    let delay = LOG_LOCK_RETRY_INITIAL_MS
+    for (;;) {
+      try {
+        await writeFile(lockPath, `${process.pid}\n`, { mode: 0o600, flag: 'wx' })
+        break
+      } catch (error: unknown) {
+        if (!isEEXIST(error)) throw error
+      }
+      if (Date.now() >= deadline) {
+        throw new Error(`session log writer lock timed out at "${lockPath}"`)
+      }
+      await new Promise(resolve => setTimeout(resolve, delay))
+      delay = Math.min(delay * 2, LOG_LOCK_RETRY_MAX_MS)
+    }
+    try {
+      return await operation()
+    } finally {
+      await rm(lockPath, { force: true })
+    }
+  }
+
   /** Encode the header and first batch without combining their frame boundaries. */
   private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise<Buffer | string> {
     const header = JSON.stringify(toHeaderLine(meta)) + '\n'
@@ -807,26 +1004,45 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi
   /** Reject metadata that does not identify the selected physical log. */
   private async assertStoredIdentity(
     path: string,
-    meta: SessionHeader,
+    meta: unknown,
     expectedId?: SessionId,
     signal?: AbortSignal,
   ): Promise<void> {
     signal?.throwIfAborted()
-    if (expectedId !== undefined && meta.id !== expectedId) {
-      throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`)
+    const identity = this.storedIdentity(meta, path)
+    if (expectedId !== undefined && identity.id !== expectedId) {
+      throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${identity.id}"`)
     }
     let expectedPath: string
     try {
-      expectedPath = logPath(this.root, meta.cwd, meta.id, this.compression)
+      expectedPath = logPath(this.root, identity.cwd, identity.id, this.compression)
     } catch (error) {
       throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error })
     }
     if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) {
-      throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`)
+      throw new Error(`corrupt session log "${path}": header id "${identity.id}" and cwd identify "${expectedPath}"`)
     }
     signal?.throwIfAborted()
   }
 
+  /** Read storage identity fields shared by every Session format version. */
+  private storedIdentity(meta: unknown, path: string): { id: SessionId; cwd?: string } {
+    if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) {
+      throw new Error(`corrupt session log "${path}": header is not a record`)
+    }
+    const record = meta as Record<string, unknown>
+    if (typeof record['id'] !== 'string') {
+      throw new Error(`corrupt session log "${path}": header id is not a string`)
+    }
+    if (record['cwd'] !== undefined && typeof record['cwd'] !== 'string') {
+      throw new Error(`corrupt session log "${path}": header cwd is not a string`)
+    }
+    return {
+      id: SessionId(record['id']),
+      ...typeof record['cwd'] === 'string' ? { cwd: record['cwd'] } : {},
+    }
+  }
+
   /**
    * Whether two path spellings resolve to the same physical file. This admits
    * case aliases on case-insensitive filesystems without weakening identity

+ 14 - 0
packages/session/session-persistence-jsonl/src/win32.ts

@@ -28,6 +28,7 @@ interface Win32ErrnoException extends NodeJS.ErrnoException {
 }
 
 const MOVEFILE_WRITE_THROUGH = 0x00000008
+const MOVEFILE_REPLACE_EXISTING = 0x00000001
 const ERROR_FILE_NOT_FOUND = 2
 const ERROR_PATH_NOT_FOUND = 3
 const ERROR_ACCESS_DENIED = 5
@@ -119,6 +120,19 @@ export async function publishNewFileWin32(existing: string, replacement: string)
   if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement)
 }
 
+/**
+ * Atomically replace an existing file with a synced staging file and request
+ * write-through namespace durability. The move stays within one volume.
+ * @param existing - synced staging path to move.
+ * @param replacement - existing final path to replace.
+ */
+export async function replaceFileWin32(existing: string, replacement: string): Promise<void> {
+  const api = await win32()
+  const flags = MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH
+  const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), flags)
+  if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement)
+}
+
 /**
  * Create `target` and its missing ancestors with durable Windows namespace
  * publication. Each missing directory is first created as a random staging

+ 338 - 14
packages/session/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -8,7 +8,12 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import {
-  encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, SessionLogScanner, toHeaderLine,
+  SessionPersistenceRevisionConflictError,
+  type StoredEventRead,
+} from '@deepseek-ai/dsh-session-persistence'
+import {
+  encodeSegment, eventLines, fromHeaderLine, logPath, parseStoredHeaderMeta, projectDir, projectKey, scanLog, sessionDir,
+  SessionLogScanner, toHeaderLine,
 } from '../src/format.ts'
 import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
 import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
@@ -16,6 +21,8 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p
 const statRace = vi.hoisted(() => ({
   path: undefined as string | undefined,
   reads: 0,
+  renamePath: undefined as string | undefined,
+  renameError: undefined as Error | undefined,
 }))
 
 vi.mock('node:fs/promises', async (importOriginal) => {
@@ -29,6 +36,25 @@ vi.mock('node:fs/promises', async (importOriginal) => {
       if (statRace.reads !== 2) return identity
       return { ...identity, mtimeNs: identity.mtimeNs + 1n }
     }) as typeof actual.stat,
+    rename: async (...args: Parameters<typeof actual.rename>) => {
+      if (String(args[1]) === statRace.renamePath && statRace.renameError !== undefined) {
+        throw statRace.renameError
+      }
+      return actual.rename(...args)
+    },
+  }
+})
+
+vi.mock('../src/win32.ts', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../src/win32.ts')>()
+  return {
+    ...actual,
+    replaceFileWin32: async (existing: string, replacement: string) => {
+      if (replacement === statRace.renamePath && statRace.renameError !== undefined) {
+        throw statRace.renameError
+      }
+      return actual.replaceFileWin32(existing, replacement)
+    },
   }
 })
 
@@ -42,6 +68,17 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader {
   return header
 }
 
+async function collectStoredRead(read: StoredEventRead<unknown>): Promise<unknown[]> {
+  const events: unknown[] = []
+  for await (const event of read.events) events.push(event)
+  await read.completed
+  return events
+}
+
+async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable<SessionEvent> {
+  for (const event of events) yield structuredClone(event)
+}
+
 /** Rewrite only a stored header while preserving every event byte below it. */
 async function rewriteHeader(path: string, update: (header: Record<string, unknown>) => void): Promise<void> {
   const lines = (await readFile(path, 'utf8')).split('\n')
@@ -86,6 +123,8 @@ function rawLogPath(root: string, cwd: string | undefined, id: SessionId): strin
 afterEach(async () => {
   statRace.path = undefined
   statRace.reads = 0
+  statRace.renamePath = undefined
+  statRace.renameError = undefined
   vi.restoreAllMocks()
   for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
 })
@@ -134,6 +173,18 @@ runCoordinatorContract('jsonl-none', async (): Promise<CoordinatorFixture> => {
 })
 
 describe('JsonlSessionPersistence: format helpers', () => {
+  it('parses only the version-independent stored header envelope', () => {
+    expect(parseStoredHeaderMeta('{')).toBeUndefined()
+    expect(parseStoredHeaderMeta('42')).toBeUndefined()
+    expect(parseStoredHeaderMeta(JSON.stringify({ type: 'event', version: 9, id: 'wrong-type' })))
+      .toBeUndefined()
+    expect(parseStoredHeaderMeta(JSON.stringify({ type: 'session', version: 9, id: 'future', futureOnly: true })))
+      .toEqual({ version: 9, id: 'future', futureOnly: true })
+    expect(parseStoredHeaderMeta(JSON.stringify({
+      type: 'session', version: 0, id: 'current', createdAt: 1, delegationDepth: 0,
+    }))).toEqual({ version: 0, id: 'current', createdAt: 1, delegationDepth: 0 })
+  })
+
   it('encodeSegment neutralizes traversal, separators, and absolute paths', () => {
     expect(encodeSegment('..')).toBe('~002E~002E')
     expect(encodeSegment('.')).toBe('~002E')
@@ -299,7 +350,8 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
     expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8'))
     expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m)))
     const scanned = scanLog(Buffer.from(raw!.content))
-    expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type))
+    expect(scanned.events.map(event => (event as SessionEvent).type))
+      .toEqual(oneTurnLog().map(event => event.type))
   })
 
   it('readRaw is undefined for an absent session', async () => {
@@ -397,28 +449,287 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
     await otherCtx.fiber.dispose()
   })
 
-  it('binds a full stored prefix to the same revision as a lightweight read', async () => {
+  it('binds a stored source to the same revision as a lightweight read', async () => {
     const m = meta('stored-prefix-revision')
     await ctx.sessionPersistence.create(m)
     await ctx.sessionPersistence.append(m.id, oneTurnLog())
     const persistence = ctx.sessionPersistence as JsonlSessionPersistence
 
-    const stored = await persistence.loadStored(m.id)
+    const stored = await persistence.openStored(m.id)
     expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
     expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
   })
 
-  it('retries a full-prefix read when the file revision changes during the read', async () => {
+  it('retries a revision-bound source read when the file changes during the read', async () => {
     const m = meta('stored-prefix-revision-race')
     await ctx.sessionPersistence.create(m)
     await ctx.sessionPersistence.append(m.id, oneTurnLog())
     const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const stored = await persistence.openStored(m.id)
+    if (stored === undefined) throw new Error('test session must be materialized')
     statRace.path = rawLogPath(root, m.cwd, m.id)
 
-    await expect(persistence.loadStored(m.id)).resolves.toMatchObject({ events: oneTurnLog() })
+    await expect(collectStoredRead(stored.readEvents())).resolves.toEqual(oneTurnLog())
     expect(statRace.reads).toBe(4)
   })
 
+  it('rejects a revision-bound source after a complete append changes its revision', async () => {
+    const m = meta('stored-source-stale')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const stored = await persistence.openStored(m.id)
+    if (stored === undefined) throw new Error('test session must be materialized')
+
+    await ctx.sessionPersistence.append(m.id, [
+      { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } },
+    ])
+
+    const read = stored.readEvents()
+    const completion = read.completed.catch((error: unknown) => error)
+    await expect(collectStoredRead(read)).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+    await expect(completion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+  })
+
+  it('retries a header read whose revision changes around the first-line read', async () => {
+    const m = meta('stored-header-revision-race')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const path = rawLogPath(root, m.cwd, m.id)
+    const internals = persistence as unknown as {
+      findLog(id: SessionId): Promise<string | undefined>
+    }
+    vi.spyOn(internals, 'findLog').mockResolvedValue(path)
+    statRace.path = path
+
+    await expect(persistence.openStored(m.id)).resolves.toMatchObject({ meta: { id: m.id } })
+    expect(statRace.reads).toBe(4)
+  })
+
+  it('reports a present empty plaintext artifact as header-less', async () => {
+    const m = meta('empty-plaintext-log')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    await writeFile(rawLogPath(root, m.cwd, m.id), '')
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+
+    await expect(persistence.openStored(m.id))
+      .rejects.toThrow('empty or header-less session log')
+  })
+
+  it('forwards prepare through the concrete backend API', async () => {
+    const m = meta('jsonl-prepare-forward')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+
+    const preparation = await persistence.prepare(m.id)
+    expect(preparation.session.id).toBe(m.id)
+    preparation[Symbol.dispose]()
+  })
+
+  it('atomically replaces one exact revision and rejects a stale replacement', async () => {
+    const m = meta('format-replace', '/work')
+    const original = [
+      ...oneTurnLog(),
+      { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
+      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
+    ] as SessionEvent[]
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, original)
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+
+    await persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog()))
+    const replaced = await persistence.openStored(m.id)
+    if (replaced === undefined) throw new Error('replacement must preserve the session')
+    expect(replaced.revision).not.toBe(source.revision)
+    expect(await collectStoredRead(replaced.readEvents())).toEqual(oneTurnLog())
+
+    await expect(
+      persistence.replaceStored(source.revision, m, replacementEvents(original)),
+    ).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+    const afterConflict = await persistence.openStored(m.id)
+    if (afterConflict === undefined) throw new Error('conflict must preserve the session')
+    expect(await collectStoredRead(afterConflict.readEvents())).toEqual(oneTurnLog())
+  })
+
+  it('preserves the old complete log when atomic replacement rename fails', async () => {
+    const m = meta('format-replace-rename-failure', '/work')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+    const path = rawLogPath(root, m.cwd, m.id)
+    const failure = new Error('simulated format replacement rename failure')
+    statRace.renamePath = path
+    statRace.renameError = failure
+
+    await expect(
+      persistence.replaceStored(source.revision, m, replacementEvents([])),
+    ).rejects.toBe(failure)
+
+    statRace.renameError = undefined
+    const preserved = await persistence.openStored(m.id)
+    if (preserved === undefined) throw new Error('failed replacement must preserve the session')
+    expect(await collectStoredRead(preserved.readEvents())).toEqual(oneTurnLog())
+    expect((await readdir(dirname(path))).some(name => name.endsWith('.upgrade.tmp'))).toBe(false)
+  })
+
+  it('rejects replacement when the artifact disappears before or after discovery', async () => {
+    const m = meta('format-replace-disappeared', '/work')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+    const path = rawLogPath(root, m.cwd, m.id)
+
+    await rm(path)
+    await expect(persistence.replaceStored(source.revision, m, replacementEvents([])))
+      .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+
+    const internals = persistence as unknown as {
+      findLog(id: SessionId): Promise<string | undefined>
+    }
+    vi.spyOn(internals, 'findLog').mockResolvedValue(path)
+    await expect(persistence.replaceStored(source.revision, m, replacementEvents([])))
+      .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+  })
+
+  it('propagates a non-absence error while rechecking a replacement source', async () => {
+    const m = meta('format-replace-header-error', '/work')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+    const failure = new Error('simulated header read failure')
+    const internals = persistence as unknown as {
+      readStoredHeader(path: string, id: SessionId): Promise<unknown>
+    }
+    vi.spyOn(internals, 'readStoredHeader').mockRejectedValue(failure)
+
+    await expect(persistence.replaceStored(source.revision, m, replacementEvents([])))
+      .rejects.toBe(failure)
+  })
+
+  it('rejects a replacement that changes cwd storage identity', async () => {
+    const m = meta('format-replace-identity', '/work')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+
+    await expect(persistence.replaceStored(
+      source.revision,
+      { ...m, cwd: '/other' },
+      replacementEvents(oneTurnLog()),
+    )).rejects.toThrow(/changes its stored identity/)
+  })
+
+  it('rejects a replacement when the source changes after the temp file is synced', async () => {
+    const m = meta('format-replace-final-cas', '/work')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+    const path = rawLogPath(root, m.cwd, m.id)
+    const internals = persistence as unknown as {
+      writeReplacement(path: string, meta: SessionHeader, events: AsyncIterable<SessionEvent>): Promise<void>
+    }
+    const writeReplacement = internals.writeReplacement.bind(internals)
+    vi.spyOn(internals, 'writeReplacement').mockImplementation(async (...args) => {
+      await writeReplacement(...args)
+      await appendFile(path, '\n')
+    })
+
+    await expect(persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog())))
+      .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+    expect((await readdir(dirname(path))).some(name => name.endsWith('.upgrade.tmp'))).toBe(false)
+  })
+
+  it('streams replacement events in bounded batches', async () => {
+    const m = meta('format-replace-batches', '/work')
+    const events = Array.from({ length: 128 }, (_, seq): SessionEvent => ({
+      type: 'turn/start', seq, time: seq + 1, data: { turn: seq + 1 },
+    }))
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+
+    await persistence.replaceStored(source.revision, m, replacementEvents(events))
+
+    const replaced = await persistence.openStored(m.id)
+    if (replaced === undefined) throw new Error('replacement must preserve the session')
+    expect(await collectStoredRead(replaced.readEvents())).toHaveLength(128)
+  })
+
+  it('retries a held writer lock and rejects lock acquisition failures and timeout', async () => {
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const internals = persistence as unknown as {
+      withLogLock<T>(path: string, operation: () => Promise<T>): Promise<T>
+    }
+    const path = join(root, 'lock-target')
+    const lockPath = `${path}.lock`
+    await writeFile(lockPath, 'held\n')
+    const released = new Promise<void>((resolveRelease) => {
+      setTimeout(() => { void rm(lockPath).then(() => { resolveRelease() }) }, 5)
+    })
+    await expect(internals.withLogLock(path, async () => 'committed')).resolves.toBe('committed')
+    await released
+
+    await expect(internals.withLogLock(`${root}\0invalid`, async () => undefined))
+      .rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' })
+
+    await writeFile(lockPath, 'held\n')
+    const now = vi.spyOn(Date, 'now')
+      .mockReturnValueOnce(1_000)
+      .mockReturnValueOnce(3_001)
+    await expect(internals.withLogLock(path, async () => undefined))
+      .rejects.toThrow(/writer lock timed out/)
+    now.mockRestore()
+  })
+
+  it('rejects malformed version-independent storage identity fields', async () => {
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const path = rawLogPath(root, '/work', SessionId('identity-fields'))
+    const internals = persistence as unknown as {
+      storedIdentity(meta: unknown, path: string): { id: SessionId; cwd?: string }
+    }
+
+    expect(() => internals.storedIdentity(null, path)).toThrow(/header is not a record/)
+    expect(() => internals.storedIdentity({ id: 1 }, path)).toThrow(/header id is not a string/)
+    expect(() => internals.storedIdentity({ id: 'identity-fields', cwd: 1 }, path))
+      .toThrow(/header cwd is not a string/)
+  })
+
+  it('rejects a physical log whose requested id differs from its header id', async () => {
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const requested = SessionId('requested-identity')
+    const path = rawLogPath(root, '/work', requested)
+    const internals = persistence as unknown as {
+      assertStoredIdentity(
+        path: string,
+        meta: unknown,
+        expectedId?: SessionId,
+      ): Promise<void>
+    }
+
+    await expect(internals.assertStoredIdentity(
+      path,
+      { id: 'different-identity', cwd: '/work' },
+      requested,
+    )).rejects.toThrow(/requested id .* does not match header id/)
+  })
+
   it('handles revision-stat races and errors after log discovery', async () => {
     const m = meta('stored-revision-race')
     await ctx.sessionPersistence.create(m)
@@ -748,7 +1059,7 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
     const beforeB = await readFile(bPath)
 
     await expect(ctx.sessionPersistence.load(a.id))
-      .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/)
+      .rejects.toThrow(/identity mismatch: requested "identity-a", header contains "identity-b"/)
     expect(await readFile(aPath)).toEqual(beforeA)
     expect(await readFile(bPath)).toEqual(beforeB)
   })
@@ -939,7 +1250,20 @@ describe('JsonlSessionPersistence: scanLog unit', () => {
 
     // The preset decides the resumed session's tools and prompt; dropping it
     // on disk would restore a composition the logged history contradicts.
-    expect(scanLog(Buffer.from(log)).meta.agentPreset).toBe('minimal')
+    expect((scanLog(Buffer.from(log)).meta as SessionHeader).agentPreset).toBe('minimal')
+  })
+
+  it('round-trips and validates a subagent origin', () => {
+    const header: SessionHeader = {
+      ...meta('subagent-origin'),
+      delegationDepth: 1,
+      origin: 'subagent',
+    }
+    const line = toHeaderLine(header)
+
+    expect(fromHeaderLine(line)).toEqual(header)
+    expect(() => scanLog(Buffer.from(`${JSON.stringify({ ...line, origin: 'parent' })}\n`)))
+      .toThrow(/session header/)
   })
 
   it('rejects a session header whose agentPreset is not a string', () => {
@@ -957,7 +1281,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => {
     // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the
     // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and
     // stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn.
-    expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
+    expect(scanLog(Buffer.from(log)).events.map(e => (e as SessionEvent).seq)).toEqual([0])
   })
 
   it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
@@ -997,7 +1321,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => {
     ].join('\n') + '\n'
     // The contiguous prefix (turn/start seq 0) is preserved; the corrupt
     // fragment after it is the tolerated crash boundary.
-    expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
+    expect(scanLog(Buffer.from(log)).events.map(e => (e as SessionEvent).seq)).toEqual([0])
   })
 
   it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
@@ -1008,7 +1332,7 @@ describe('JsonlSessionPersistence: scanLog unit', () => {
       JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
     ].join('\n') + '\n'
     const { events } = scanLog(Buffer.from(log))
-    expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped
+    expect(events.map(e => (e as SessionEvent).seq)).toEqual([0, 1]) // tail dropped
   })
 })
 
@@ -1129,7 +1453,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => {
       JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
     ].join('\n') + '\n'
     const { events } = scanLog(Buffer.from(logText))
-    expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4])
+    expect(events.map(e => (e as SessionEvent).seq)).toEqual([0, 1, 2, 3, 4])
     expect(events[2]).toEqual({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } })
   })
 
@@ -1151,7 +1475,7 @@ describe('JsonlSessionPersistence: default packed chunk rows', () => {
       JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
     ].join('\n') + '\n'
     const scanned = scanLog(Buffer.from(logText))
-    expect(scanned.events.map(e => e.seq)).toEqual([0])
+    expect(scanned.events.map(e => (e as SessionEvent).seq)).toEqual([0])
     // committedBytes stays on the line boundary BEFORE the dropped row.
     const headerAndTurn = logText.split('\n').slice(0, 2).join('\n') + '\n'
     expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8'))
@@ -1393,7 +1717,7 @@ describe('JsonlSessionPersistence: edge cases', () => {
     // The "/w" log is untouched — no no-cwd events were grafted onto it, and no
     // `_no-cwd` log for "x" was created.
     const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x'))))
-    expect(inW.meta.cwd).toBe('/w')
+    expect((inW.meta as SessionHeader).cwd).toBe('/w')
     expect(inW.events).toHaveLength(6)
     await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow()
     await ctx2.fiber.dispose()

+ 36 - 0
packages/session/session-persistence-jsonl/tests/win32.spec.ts

@@ -11,6 +11,7 @@ import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 
 const MOVEFILE_WRITE_THROUGH = 0x00000008
+const MOVEFILE_REPLACE_EXISTING = 0x00000001
 const ERROR_FILE_NOT_FOUND = 2
 const ERROR_PATH_NOT_FOUND = 3
 const ERROR_ACCESS_DENIED = 5
@@ -90,6 +91,17 @@ async function importWithFilesystemMove(): Promise<typeof import('../src/win32.t
   })
 }
 
+async function importWithFilesystemReplace(): Promise<typeof import('../src/win32.ts')> {
+  return importWithMove((existing, replacement, flags, setLastError) => {
+    expect(flags).toBe(MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)
+    const from = stripNamespace(existing)
+    const to = stripNamespace(replacement)
+    if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
+    renameSync(from, to)
+    return 1
+  })
+}
+
 afterEach(async () => {
   vi.doUnmock('koffi')
   vi.doUnmock('node:fs/promises')
@@ -141,6 +153,30 @@ describe('Windows durable namespace helpers', () => {
     expect(readFileSync(final, 'utf8')).toBe('content')
   })
 
+  it('replaces an existing file with write-through MoveFileExW semantics', async () => {
+    const { replaceFileWin32 } = await importWithFilesystemReplace()
+    const root = await tempRoot()
+    const tmp = join(root, 'log.tmp')
+    const final = join(root, 'log.jsonl')
+    await writeFile(tmp, 'replacement')
+    await writeFile(final, 'original')
+
+    await replaceFileWin32(tmp, final)
+    expect(existsSync(tmp)).toBe(false)
+    expect(readFileSync(final, 'utf8')).toBe('replacement')
+  })
+
+  it('maps a Win32 replacement failure to a Node-style error', async () => {
+    const { replaceFileWin32 } = await importWithError(ERROR_ACCESS_DENIED)
+
+    await expect(replaceFileWin32('from', 'to')).rejects.toMatchObject({
+      code: 'EACCES',
+      win32Code: ERROR_ACCESS_DENIED,
+      path: 'from',
+      dest: 'to',
+    })
+  })
+
   it('maps Win32 publish failures to Node-style errno codes', async () => {
     const cases = [
       [ERROR_FILE_NOT_FOUND, 'ENOENT'],

+ 50 - 2
packages/session/session-persistence-jsonl/tests/zstd.spec.ts

@@ -332,6 +332,27 @@ describe('Zstandard frame structure', () => {
 })
 
 describe('JsonlSessionPersistence: default Zstandard encoding', () => {
+  it('atomically replaces a stored revision with compressed header and event frames', async () => {
+    const root = await freshRoot()
+    const ctx = await mount(root)
+    const header = meta('replace-zstd', '/work')
+    await ctx.sessionPersistence.create(header)
+    await ctx.sessionPersistence.append(header.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(header.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+    const replacement = oneTurnLog().slice(0, 2)
+
+    await persistence.replaceStored(source.revision, header, (async function* () {
+      yield* replacement
+    })())
+
+    const buffer = await readFile(logPath(root, header.cwd, header.id, 'zstd'))
+    expect(scanZstdFrames(buffer).frames).toHaveLength(2)
+    const plaintext = (await decodeCompleteFrames(buffer)).toString()
+    expect(scanLog(Buffer.from(plaintext)).events).toEqual(replacement)
+  })
+
   it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => {
     const root = await freshRoot()
     const ctx = await mount(root)
@@ -374,7 +395,8 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => {
       '',
     ].join('\n'))
     const scanned = scanLog(Buffer.from(raw!.content))
-    expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type))
+    expect(scanned.events.map(event => (event as SessionEvent).type))
+      .toEqual(oneTurnLog().map(event => event.type))
   })
 
   it('readRaw rejects a present zstd artifact that carries no frame', async () => {
@@ -387,6 +409,32 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => {
     await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0))
     await expect(ctx.sessionPersistence.readRaw(header.id))
       .rejects.toThrow('empty or header-less Zstandard session log')
+    await expect(ctx.sessionPersistence.load(header.id))
+      .rejects.toThrow('empty or header-less Zstandard session log')
+  })
+
+  it('rejects a zero-frame artifact through an already-open stored reader', async () => {
+    const root = await freshRoot()
+    const ctx = await mount(root)
+    const header = meta('stored-zero-frame', '/work')
+    await ctx.sessionPersistence.create(header)
+    await ctx.sessionPersistence.append(header.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as JsonlSessionPersistence
+    const source = await persistence.openStored(header.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+    await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0))
+
+    const read = source.readEvents()
+    const completion = read.completed.catch((error: unknown) => error)
+    const consumption = (async (): Promise<void> => {
+      for await (const _event of read.events) {
+        // A zero-frame artifact cannot yield a logical event.
+      }
+    })().catch((error: unknown) => error)
+    const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
+
+    expect(streamFailure).toBe(completionFailure)
+    expect(streamFailure).toMatchObject({ message: 'empty or header-less Zstandard session log' })
   })
 
   it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => {
@@ -727,7 +775,7 @@ describe('JsonlSessionPersistence: encoding selection', () => {
       '',
     ].join('\n'))
     await expect(ctx.sessionPersistence.load(loadHeader.id)).rejects.toThrow(/uses \.jsonl/)
-    await expect((ctx.sessionPersistence as JsonlSessionPersistence).loadStored(loadHeader.id))
+    await expect((ctx.sessionPersistence as JsonlSessionPersistence).openStored(loadHeader.id))
       .rejects.toThrow(/uses \.jsonl/)
     await expect(ctx.sessionPersistence.list()).rejects.toThrow(/uses \.jsonl/)
   })

+ 188 - 20
packages/session/session-persistence-sqlite/src/index.ts

@@ -15,14 +15,16 @@ import { mkdir, open } from 'node:fs/promises'
 import { dirname, resolve } from 'node:path'
 import {
   DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
-  SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
+  decodeStoredSessionHeader, SessionPersistence, SessionPersistenceRevision,
+  SessionPersistenceRevisionConflictError, PersistenceCoordinator,
   type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
   type SessionInspection, type SessionPersistenceRevision as PersistenceRevision,
-  type StoredPrefix, type StoredSuffix,
+  type StoredEventRead, type StoredSessionSource,
 } from '@deepseek-ai/dsh-session-persistence'
-import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SurfaceEventType, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
 import {
-  type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow,
+  type JournalMode, openDatabase, rowToStoredMeta, scanRows, type EventRow, type SessionRow,
 } from './schema.ts'
 
 export { SCHEMA_VERSION } from './schema.ts'
@@ -50,6 +52,27 @@ function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevi
   )
 }
 
+interface SqliteStoredPrefix {
+  readonly meta: unknown
+  readonly events: unknown[]
+  readonly revision: PersistenceRevision
+  readonly tornMarker?: number
+}
+
+function deferred<T>(): {
+  readonly promise: Promise<T>
+  resolve(value: T): void
+  reject(reason: unknown): void
+} {
+  let resolve!: (value: T) => void
+  let reject!: (reason: unknown) => void
+  const promise = new Promise<T>((accept, decline) => {
+    resolve = accept
+    reject = decline
+  })
+  return { promise, resolve, reject }
+}
+
 /**
  * Exclusively create a missing database file with owner-only permissions.
  * Existing files retain their modes, and errors other than `EEXIST` propagate.
@@ -203,9 +226,43 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers
 
   // --- PersistenceBackend hooks (the SQLite storage primitives) ---
 
-  /** Read a stored prefix by id (ids are globally unique — no scope to scan). */
-  loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | undefined> {
-    return this.readPrefix(id, signal)
+  /** Open repeatable reads over one SQLite row revision. */
+  async openStored(id: SessionId, signal?: AbortSignal): Promise<StoredSessionSource<number> | undefined> {
+    signal?.throwIfAborted()
+    await this.ready
+    signal?.throwIfAborted()
+    const row = this.rowFor(id)
+    if (row === undefined) return undefined
+    const revision = sqliteRevision(this.storeIdentity, row)
+    return {
+      meta: rowToStoredMeta(row),
+      revision,
+      readEvents: (options = {}): StoredEventRead<number> => {
+        const completed = deferred<{ tornMarker?: number }>()
+        const events = (async function* (backend: SqliteSessionPersistence): AsyncIterable<unknown> {
+          try {
+            const fromSeq = options.fromSeq ?? 0
+            const stored = fromSeq === 0
+              ? await backend.readPrefix(id, signal)
+              : await backend.readSuffix(id, fromSeq, signal)
+            if (stored === undefined || stored.revision !== revision) {
+              throw new SessionPersistenceRevisionConflictError(
+                `session "${id}" changed while reading revision ${revision}`,
+              )
+            }
+            for (const event of stored.events) {
+              signal?.throwIfAborted()
+              yield event
+            }
+            completed.resolve(stored.tornMarker === undefined ? {} : { tornMarker: stored.tornMarker })
+          } catch (error: unknown) {
+            completed.reject(error)
+            throw error
+          }
+        })(this)
+        return { events, completed: completed.promise }
+      },
+    }
   }
 
   /** Read one row's revision without loading its events. */
@@ -222,27 +279,42 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers
    * read scales with the suffix, not the log. Torn rows past the preserved
    * region are dropped, never repaired (non-mutating read).
    */
-  async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined> {
+  private async readSuffix(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<SqliteStoredPrefix | undefined> {
     signal?.throwIfAborted()
     await this.ready
     signal?.throwIfAborted()
-    const row = this.rowFor(id)
-    if (row === undefined) return undefined
-    const meta = rowToMeta(row)
-    const eventRows = this.db
-      .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
-      .all(id, fromSeq) as unknown as EventRow[]
+    this.db.exec('BEGIN')
+    let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined
+    try {
+      const row = this.rowFor(id)
+      if (row !== undefined) {
+        const eventRows = this.db
+          .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
+          .all(id, fromSeq) as unknown as EventRow[]
+        snapshot = { row, eventRows }
+      }
+      this.db.exec('COMMIT')
+    } catch (error: unknown) {
+      this.db.exec('ROLLBACK')
+      throw error
+    }
     signal?.throwIfAborted()
+    if (snapshot === undefined) return undefined
+    const { row, eventRows } = snapshot
     const { preserved } = scanRows(eventRows, fromSeq)
-    return { meta, events: preserved }
+    return {
+      meta: rowToStoredMeta(row),
+      events: preserved,
+      revision: sqliteRevision(this.storeIdentity, row),
+    }
   }
 
   /**
-   * Read a session's row + ordered events into a {@link StoredPrefix}. The
+   * Read a session's row and ordered events at one SQLite snapshot. The
    * torn-tail marker is the seq from which a never-committed tail must be deleted
    * (`scanRows` already returns it as `number | undefined`).
    */
-  private async readPrefix(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | undefined> {
+  private async readPrefix(id: SessionId, signal?: AbortSignal): Promise<SqliteStoredPrefix | undefined> {
     signal?.throwIfAborted()
     await this.ready
     signal?.throwIfAborted()
@@ -268,7 +340,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers
     const { row, eventRows } = snapshot
     const { preserved, tornFrom } = scanRows(eventRows)
     return {
-      meta: rowToMeta(row),
+      meta: rowToStoredMeta(row),
       events: preserved,
       revision: sqliteRevision(this.storeIdentity, row),
       ...tornFrom !== undefined ? { tornMarker: tornFrom } : {},
@@ -337,6 +409,102 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers
     }
   }
 
+  /** Stage a streamed replacement, then compare and swap it in one transaction. */
+  async replaceStored(
+    expectedRevision: PersistenceRevision,
+    meta: SessionHeader,
+    events: AsyncIterable<SessionEvent>,
+  ): Promise<void> {
+    await this.ready
+    const observed = this.rowFor(meta.id)
+    if (observed === undefined
+      || sqliteRevision(this.storeIdentity, observed) !== expectedRevision) {
+      throw new SessionPersistenceRevisionConflictError(
+        `session "${meta.id}" changed before replacement of revision ${expectedRevision}`,
+      )
+    }
+    if (meta.cwd !== (observed.cwd ?? undefined)) {
+      throw new Error(`replacement for session "${meta.id}" changes its stored identity`)
+    }
+    const staging = `format_upgrade_${randomUUID().replaceAll('-', '')}`
+    this.db.exec(`
+      CREATE TEMP TABLE ${staging} (
+        seq               INTEGER PRIMARY KEY,
+        type              TEXT NOT NULL,
+        time              INTEGER NOT NULL,
+        data              TEXT NOT NULL,
+        source_event_seqs TEXT,
+        surface_op        TEXT,
+        ignorable         INTEGER
+      ) STRICT
+    `)
+    try {
+      const stageEvent = this.db.prepare(
+        `INSERT INTO ${staging} (seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+      )
+      for await (const event of events) {
+        const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
+        stageEvent.run(event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
+      }
+
+      this.db.exec('BEGIN IMMEDIATE')
+      let began = true
+      try {
+        const row = this.rowFor(meta.id)
+        if (row === undefined
+          || sqliteRevision(this.storeIdentity, row) !== expectedRevision) {
+          this.db.exec('ROLLBACK')
+          began = false
+          throw new SessionPersistenceRevisionConflictError(
+            `session "${meta.id}" changed before replacement of revision ${expectedRevision}`,
+          )
+        }
+        if (meta.cwd !== (row.cwd ?? undefined)) {
+          throw new Error(`replacement for session "${meta.id}" changes its stored identity`)
+        }
+
+        this.db.prepare('DELETE FROM events WHERE session_id = ?').run(meta.id)
+        this.db.prepare(`
+          INSERT INTO events
+            (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable)
+          SELECT ?, seq, type, time, data, source_event_seqs, surface_op, ignorable
+          FROM ${staging}
+          ORDER BY seq
+        `).run(meta.id)
+        this.db.prepare(`
+          UPDATE sessions SET
+            version = ?,
+            created_at = ?,
+            cwd = ?,
+            parent_session = ?,
+            seed_length = ?,
+            origin = ?,
+            delegation_depth = ?,
+            agent_preset = ?,
+            revision = revision + 1
+          WHERE id = ?
+        `).run(
+          meta.version,
+          meta.createdAt,
+          meta.cwd ?? null,
+          meta.parentSession ?? null,
+          meta.seedLength ?? null,
+          meta.origin ?? null,
+          meta.delegationDepth ?? null,
+          meta.agentPreset ?? null,
+          meta.id,
+        )
+        this.db.exec('COMMIT')
+        began = false
+      } catch (error: unknown) {
+        if (began) this.db.exec('ROLLBACK')
+        throw error
+      }
+    } finally {
+      this.db.exec(`DROP TABLE IF EXISTS ${staging}`)
+    }
+  }
+
   /** List all materialized sessions' metadata (every row is a materialized session). */
   async list(signal?: AbortSignal): Promise<SessionHeader[]> {
     signal?.throwIfAborted()
@@ -346,7 +514,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers
       .prepare('SELECT * FROM sessions')
       .all() as unknown as SessionRow[]
     signal?.throwIfAborted()
-    return rows.map(rowToMeta)
+    return rows.map(row => decodeStoredSessionHeader(rowToStoredMeta(row), SessionId(row.id)))
   }
 
   /** List metadata with a source-qualified monotonic revision per session. */
@@ -357,7 +525,7 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers
     const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[]
     signal?.throwIfAborted()
     return rows.map(row => ({
-      header: rowToMeta(row),
+      header: decodeStoredSessionHeader(rowToStoredMeta(row), SessionId(row.id)),
       revision: SessionPersistenceRevision(
         `${this.storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
       ),

+ 21 - 1
packages/session/session-persistence-sqlite/src/schema.ts

@@ -193,6 +193,26 @@ export function rowToMeta(row: SessionRow): SessionHeader {
   }
 }
 
+/**
+ * Reconstruct parsed logical header JSON without applying the current Session
+ * format type. The versioned decoder owns structural migration and validation.
+ * @param row - stored session row.
+ * @returns logical header fields represented by the physical schema.
+ */
+export function rowToStoredMeta(row: SessionRow): unknown {
+  return {
+    version: row.version,
+    id: row.id,
+    createdAt: row.created_at,
+    ...row.cwd !== null ? { cwd: row.cwd } : {},
+    ...row.parent_session !== null ? { parentSession: row.parent_session } : {},
+    ...row.seed_length !== null ? { seedLength: row.seed_length } : {},
+    ...row.origin !== null ? { origin: row.origin } : {},
+    ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
+    ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {},
+  }
+}
+
 /**
  * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
  * @param row - the `events` table row; `data` and the surface columns hold JSON text.
@@ -225,7 +245,7 @@ export function rowToEvent(row: EventRow): SessionEvent {
  *
  * @param rows - one session's event rows, ordered by seq ascending.
  * @param base - the seq the first row is expected to carry; `0` for a whole
- *   log, the requested `fromSeq` for a suffix read (`loadStoredFrom`).
+ *   log, or the requested `fromSeq` for a seekable suffix read.
  * @returns the preserved event prefix, plus `tornFrom` — the seq the physical
  *   delete starts at — when a torn tail exists.
  */

+ 212 - 3
packages/session/session-persistence-sqlite/tests/sqlite.spec.ts

@@ -1,5 +1,5 @@
 import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { existsSync } from 'node:fs'
 import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
@@ -9,10 +9,12 @@ import { DatabaseSync } from 'node:sqlite'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
 import SqliteSessionPersistence, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
+import { SessionPersistenceRevisionConflictError } from '@deepseek-ai/dsh-session-persistence'
 import {
   openDatabase,
   rowToEvent,
   rowToMeta,
+  rowToStoredMeta,
   scanRows,
   SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
   type EventRow,
@@ -48,6 +50,10 @@ async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () =
   return { ctx, dispose: () => fiber.dispose() }
 }
 
+async function* replacementEvents(events: readonly SessionEvent[]): AsyncIterable<SessionEvent> {
+  for (const event of events) yield structuredClone(event)
+}
+
 // Run the same backend-agnostic contract as JSONL to pin identical semantics.
 runPersistenceContract('sqlite', async () => {
   const ctx = new Context()
@@ -161,6 +167,53 @@ describe('scanRows', () => {
 })
 
 describe('rowToMeta', () => {
+  it('projects every optional physical header field without current-format decoding', () => {
+    const row = {
+      id: 'stored-meta',
+      version: 7,
+      created_at: 1,
+      cwd: '/work',
+      parent_session: 'parent',
+      seed_length: 3,
+      origin: 'subagent' as const,
+      incarnation: 'stored-meta',
+      revision: 1,
+      delegation_depth: 2,
+      agent_preset: 'minimal',
+    }
+    expect(rowToStoredMeta(row)).toEqual({
+      version: 7,
+      id: 'stored-meta',
+      createdAt: 1,
+      cwd: '/work',
+      parentSession: 'parent',
+      seedLength: 3,
+      origin: 'subagent',
+      delegationDepth: 2,
+      agentPreset: 'minimal',
+    })
+    expect(rowToStoredMeta({
+      ...row,
+      cwd: null,
+      parent_session: null,
+      seed_length: null,
+      origin: null,
+      delegation_depth: null,
+      agent_preset: null,
+    })).toEqual({ version: 7, id: 'stored-meta', createdAt: 1 })
+    expect(rowToMeta(row)).toEqual({
+      version: 7,
+      id: 'stored-meta',
+      createdAt: 1,
+      cwd: '/work',
+      parentSession: 'parent',
+      seedLength: 3,
+      origin: 'subagent',
+      delegationDepth: 2,
+      agentPreset: 'minimal',
+    })
+  })
+
   it('restores optional origin metadata', () => {
     expect(rowToMeta({
       id: 'with-origin',
@@ -597,19 +650,175 @@ describe('SqliteSessionPersistence: durability and crash semantics', () => {
     await b.dispose()
   })
 
-  it('binds a full stored prefix to the same revision as a lightweight read', async () => {
+  it('binds a stored source to the same revision as a lightweight read', async () => {
     const b = await backend()
     const m = meta('stored-prefix-revision')
     await b.ctx.sessionPersistence.create(m)
     await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
     const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
 
-    const stored = await persistence.loadStored(m.id)
+    const stored = await persistence.openStored(m.id)
     expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
     expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
     await b.dispose()
   })
 
+  it('rejects revision-bound full and suffix readers after the row changes or disappears', async () => {
+    const { ctx, dispose } = await backend()
+    const m = meta('stored-reader-conflict')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = ctx.sessionPersistence as SqliteSessionPersistence
+    const changed = await persistence.openStored(m.id)
+    if (changed === undefined) throw new Error('test session must be materialized')
+    await ctx.sessionPersistence.append(m.id, [
+      { type: 'turn/start', seq: oneTurnLog().length, time: 7, data: { turn: 2 } },
+    ])
+    const changedRead = changed.readEvents()
+    const changedCompletion = changedRead.completed.catch((error: unknown) => error)
+    await expect((async () => { for await (const _event of changedRead.events) { /* consume */ } })())
+      .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+    await expect(changedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+
+    const removed = await persistence.openStored(m.id)
+    if (removed === undefined) throw new Error('test session must remain materialized')
+    const internals = persistence as unknown as { db: DatabaseSync }
+    internals.db.prepare('DELETE FROM sessions WHERE id = ?').run(m.id)
+    const removedRead = removed.readEvents({ fromSeq: 1 })
+    const removedCompletion = removedRead.completed.catch((error: unknown) => error)
+    await expect((async () => { for await (const _event of removedRead.events) { /* consume */ } })())
+      .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+    await expect(removedCompletion).resolves.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+    await dispose()
+  })
+
+  it('rolls back a suffix snapshot when its SQL read fails and reports absent direct snapshots', async () => {
+    const { ctx, dispose } = await backend()
+    const persistence = ctx.sessionPersistence as SqliteSessionPersistence
+    const internals = persistence as unknown as {
+      db: DatabaseSync
+      readPrefix(id: SessionId): Promise<unknown>
+      readSuffix(id: SessionId, fromSeq: number): Promise<unknown>
+    }
+    expect(await internals.readPrefix(SessionId('missing-prefix'))).toBeUndefined()
+    expect(await internals.readSuffix(SessionId('missing-suffix'), 1)).toBeUndefined()
+
+    const m = meta('suffix-rollback')
+    await ctx.sessionPersistence.create(m)
+    await ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const prepare = internals.db.prepare.bind(internals.db)
+    const spy = vi.spyOn(internals.db, 'prepare').mockImplementation((sql) => {
+      if (sql.includes('seq >= ?')) throw new Error('simulated suffix SELECT failure')
+      return prepare(sql)
+    })
+    await expect(internals.readSuffix(m.id, 1)).rejects.toThrow('simulated suffix SELECT failure')
+    spy.mockRestore()
+    expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n)
+      .toBe(oneTurnLog().length)
+    await dispose()
+  })
+
+  it('atomically replaces one exact revision and rejects a stale replacement', async () => {
+    const b = await backend()
+    const m = meta('format-replace')
+    const original = [
+      ...oneTurnLog(),
+      { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
+      { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
+    ] as SessionEvent[]
+    await b.ctx.sessionPersistence.create(m)
+    await b.ctx.sessionPersistence.append(m.id, original)
+    const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+
+    await persistence.replaceStored(source.revision, m, replacementEvents(oneTurnLog()))
+    const replaced = await persistence.openStored(m.id)
+    if (replaced === undefined) throw new Error('replacement must preserve the session')
+    expect(replaced.revision).not.toBe(source.revision)
+    expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog())
+
+    await expect(
+      persistence.replaceStored(source.revision, m, replacementEvents(original)),
+    ).rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+    expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog())
+    await b.dispose()
+  })
+
+  it('rejects replacement identity changes before and during the transaction', async () => {
+    const first = await backend()
+    const m = meta('format-replace-identity', '/work')
+    await first.ctx.sessionPersistence.create(m)
+    await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = first.ctx.sessionPersistence as SqliteSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+
+    await expect(persistence.replaceStored(
+      source.revision,
+      { ...m, cwd: '/other' },
+      replacementEvents(oneTurnLog()),
+    )).rejects.toThrow(/changes its stored identity/)
+
+    const internals = persistence as unknown as { db: DatabaseSync }
+    const changesDuringStaging = (async function* (): AsyncIterable<SessionEvent> {
+      yield* oneTurnLog()
+      internals.db.prepare('UPDATE sessions SET cwd = ? WHERE id = ?').run('/raced', m.id)
+    })()
+    await expect(persistence.replaceStored(source.revision, m, changesDuringStaging))
+      .rejects.toThrow(/changes its stored identity/)
+    expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n)
+      .toBe(oneTurnLog().length)
+    await first.dispose()
+  })
+
+  it('rejects a revision change that occurs while replacement events are staged', async () => {
+    const mounted = await backend()
+    const m = meta('format-replace-staging-race', '/work')
+    await mounted.ctx.sessionPersistence.create(m)
+    await mounted.ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = mounted.ctx.sessionPersistence as SqliteSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+    const internals = persistence as unknown as { db: DatabaseSync }
+    const changesDuringStaging = (async function* (): AsyncIterable<SessionEvent> {
+      yield* oneTurnLog()
+      internals.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(m.id)
+    })()
+
+    await expect(persistence.replaceStored(source.revision, m, changesDuringStaging))
+      .rejects.toBeInstanceOf(SessionPersistenceRevisionConflictError)
+    expect((internals.db.prepare('SELECT COUNT(*) AS n FROM events WHERE session_id = ?').get(m.id) as { n: number }).n)
+      .toBe(oneTurnLog().length)
+    await mounted.dispose()
+  })
+
+  it('rolls back the complete replacement when the transaction fails after it begins', async () => {
+    const b = await backend()
+    const m = meta('format-replace-rollback')
+    await b.ctx.sessionPersistence.create(m)
+    await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
+    const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
+    const source = await persistence.openStored(m.id)
+    if (source === undefined) throw new Error('test session must be materialized')
+    const db = (persistence as unknown as { db: DatabaseSync }).db
+    db.exec(`
+      CREATE TEMP TRIGGER fail_format_replace
+      BEFORE UPDATE ON sessions
+      BEGIN
+        SELECT RAISE(ABORT, 'simulated format replacement failure');
+      END
+    `)
+
+    await expect(
+      persistence.replaceStored(source.revision, m, replacementEvents([])),
+    ).rejects.toThrow(/simulated format replacement failure/)
+    db.exec('DROP TRIGGER fail_format_replace')
+
+    expect((await b.ctx.sessionPersistence.readFrom(m.id, 0)).events).toEqual(oneTurnLog())
+    await b.dispose()
+  })
+
   it('changes revisions when a deleted session id is materialized again in the same database', async () => {
     const path = await freshDbPath()
     const m = meta('recreated-revision')

+ 157 - 521
packages/session/session-persistence/src/coordinator.ts

@@ -9,15 +9,22 @@ import { Context } from '@deepseek-ai/cordis'
 import {
   adoptSessionEvent,
   interruptedTurnClosers,
-  KNOWN_SESSION_EVENT_TYPES,
   SESSION_FORMAT_VERSION,
   SessionPreparation,
   snapshotJsonValue,
-  snapshotSessionEvent,
 } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
-import type { SessionInspection, SessionLocation } from './index.ts'
+import type { SessionInspection } from './index.ts'
+import {
+  decodeStoredSession,
+  SessionFormatUnsupportedError,
+} from './format-decoder.ts'
+import type {
+  DecodedSession,
+  StoredSessionSource,
+} from './format-decoder.ts'
+import { SessionPersistenceRevisionConflictError } from './revision.ts'
 import type { SessionPersistenceRevision } from './revision.ts'
 import { observeQueuedAbort, SessionPreparations } from './preparations.ts'
 import type { SessionPreparationReservation } from './preparations.ts'
@@ -44,42 +51,6 @@ export class SessionPersistenceCorruptionError extends Error {
   }
 }
 
-/**
- * The stored log is intact but this runtime cannot faithfully interpret it:
- * the header carries an unsupported format version, or an event's type is
- * unknown to this build and the event is not marked ignorable. Distinct from
- * {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log
- * remains readable at {@link location} when the backend keeps one artifact
- * per session.
- */
-export class SessionFormatUnsupportedError extends Error {
-  /**
-   * @param message - stable reason the log cannot be interpreted, already
-   *   including the raw-log path when one exists.
-   * @param location - the backend's artifact location, when one exists.
-   */
-  constructor(message: string, readonly location?: SessionLocation) {
-    super(message)
-    this.name = 'SessionFormatUnsupportedError'
-  }
-}
-
-/**
- * Direction-aware refusal text for a stored session whose format version this
- * build does not read. Shared by the coordinator's load-time check and by
- * backends that must refuse BEFORE decoding version-dependent structure (a
- * future format may not satisfy today's structural checks at all, and the
- * user must see "upgrade the harness", never "corrupt").
- * @param id - the stored session id, for message context.
- * @param version - the stored format version.
- * @returns the stable refusal text, without a raw-log path suffix.
- */
-export function sessionFormatVersionRefusal(id: string, version: number): string {
-  return version > SESSION_FORMAT_VERSION
-    ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`
-    : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`
-}
-
 /** Coordinator policy supplied by a concrete persistence backend. */
 export interface PersistenceCoordinatorOptions {
   /** Maximum completed unpublished preparations retained for reuse. */
@@ -88,32 +59,6 @@ export interface PersistenceCoordinatorOptions {
   readonly writeBatchMaxDelayMs: number
 }
 
-/**
- * A stored session's header, valid contiguous event prefix, source-qualified
- * revision, and optional opaque torn-tail marker. The revision identifies the
- * exact detached prefix. The coordinator only checks marker presence and
- * returns its value to {@link PersistenceBackend.commitRepair}; each backend
- * owns the marker type.
- */
-export interface StoredPrefix<TornMarker = unknown> {
-  meta: SessionHeader
-  events: SessionEvent[]
-  /** Revision observed for exactly this detached prefix. */
-  revision: SessionPersistenceRevision
-  tornMarker?: TornMarker
-}
-
-/**
- * A stored session's header plus the events at or past a requested seq — the
- * return shape of the optional seek-capable
- * {@link PersistenceBackend.loadStoredFrom} hook. Non-mutating reads carry no
- * torn marker: there is nothing to repair.
- */
-export interface StoredSuffix {
-  meta: SessionHeader
-  events: SessionEvent[]
-}
-
 /**
  * The storage contract between {@link PersistenceCoordinator} and a concrete
  * backend: the minimal set of durable primitives the orchestration calls. A
@@ -121,27 +66,21 @@ export interface StoredSuffix {
  * coordinator supplies everything else (buffering, serialization, cursors,
  * adoption, crash repair sequencing, dispose quiescence).
  *
- * @typeParam TornMarker - the backend's opaque torn-tail repair token (see
- * {@link StoredPrefix}). The coordinator treats it as fully opaque.
+ * @typeParam TornMarker - the backend's opaque torn-tail repair token returned
+ * after a complete event read. The coordinator treats it as fully opaque.
  */
 export interface PersistenceBackend<TornMarker = unknown> {
   /** Human-readable backend name, used in the dispose-failure AggregateError. */
   readonly name: string
 
   /**
-   * Read a stored prefix by id, scanning every backend storage scope. Returns
-   * `undefined` if no stored artifact exists. Returned metadata must identify
-   * `id` before repair or state publication. Used by resume/load, live adoption,
-   * and — via `!== undefined` — the create-collision probe. The returned
-   * `tornMarker` is present iff there is a torn tail to truncate. Every header
-   * and event graph must be fresh, mutually unaliased, and unretained by the
-   * backend because preparation freezes and publishes them in place. The
-   * returned revision must identify exactly those values and use the same
-   * representation as {@link readStoredRevision}.
+   * Open repeatable access to one stored revision by id, scanning every backend
+   * storage scope. Returns `undefined` if no artifact exists. Each event reader
+   * reproduces this revision or rejects when a concurrent writer changed it.
    * @param id - persisted session id to resolve.
    * @param signal - optional cancellation for backend read work.
    */
-  loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<TornMarker> | undefined>
+  openStored(id: SessionId, signal?: AbortSignal): Promise<StoredSessionSource<TornMarker> | undefined>
 
   /**
    * Read the current source-qualified revision for one stored session without
@@ -151,30 +90,6 @@ export interface PersistenceBackend<TornMarker = unknown> {
    */
   readStoredRevision(id: SessionId, signal?: AbortSignal): Promise<SessionPersistenceRevision | undefined>
 
-  /**
-   * Optional seek-capable suffix read behind the service's `readFrom`: return
-   * the header plus the stored events with `seq >= fromSeq` without reading
-   * the whole log. A backend whose medium can address events by seq (SQLite)
-   * implements this so `readFrom` scales with the suffix; sequential backends
-   * omit it and the coordinator falls back to {@link loadStored} plus a
-   * forward skip. Non-mutating (no truncation, no closers). Validation of the
-   * region strictly below `fromSeq` is limited to seq contiguity — the
-   * service contract scopes this read to the suffix — unless that suffix
-   * contains a supported legacy shape whose normalization needs earlier
-   * message-identity facts, in which case the coordinator falls back
-   * to the complete stored prefix.
-   * Unknown-type refusal follows the same suffix scope: a seek-capable
-   * backend's `readFrom` checks only the returned suffix, while the
-   * sequential fallback parses the whole artifact and refuses on an unknown
-   * required event anywhere in it — over-refusal on the sequential side is
-   * accepted rather than widening the seek read.
-   * @param id - persisted session id to resolve.
-   * @param fromSeq - first event seq to include (non-negative safe integer,
-   *   validated by the coordinator before this hook runs).
-   * @param signal - optional cancellation for backend read work.
-   */
-  loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined>
-
   /**
    * Durably append a CONTIGUOUS batch, lazily materializing the session first
    * when `!isMaterialized`. The materialize-write and the first event batch MUST
@@ -193,18 +108,24 @@ export interface PersistenceBackend<TornMarker = unknown> {
   commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>
 
   /**
-   * List all stored (materialized) sessions' metadata.
-   * @param signal - optional cancellation for backend listing work.
+   * Atomically replace one exact stored revision with a complete current log.
+   * The backend checks revision and storage identity in the same exclusive file
+   * operation or database transaction that commits the replacement.
+   * @param expectedRevision - exact source revision decoded by the caller.
+   * @param meta - complete current-format header.
+   * @param events - complete current-format event stream.
    */
-  list(signal?: AbortSignal): Promise<SessionHeader[]>
+  replaceStored(
+    expectedRevision: SessionPersistenceRevision,
+    meta: SessionHeader,
+    events: AsyncIterable<SessionEvent>,
+  ): Promise<void>
 
   /**
-   * Optional side-effect-free artifact locator, used to point refusal
-   * diagnostics ({@link SessionFormatUnsupportedError}) at the raw log.
-   * Backends without one artifact per session omit it or return `undefined`.
-   * @param meta - the header whose artifact is requested.
+   * List all stored (materialized) sessions' metadata.
+   * @param signal - optional cancellation for backend listing work.
    */
-  locate?(meta: SessionHeader): SessionLocation | undefined
+  list(signal?: AbortSignal): Promise<SessionHeader[]>
 
   /**
    * Optional lifecycle teardown (e.g. close a database handle). Awaited by the
@@ -245,6 +166,7 @@ interface PreparedSessionSource<TornMarker> {
   readonly inspection: SessionInspection
   readonly session: Session
   readonly revision: SessionPersistenceRevision
+  readonly sourceVersion: number
   /** Session length after constructor-owned seed markers were appended. */
   readonly sessionLength: number
   readonly tornMarker: TornMarker | undefined
@@ -270,7 +192,7 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
     })
 }
 
-/** Reject events from an obsolete v0 vocabulary that this build cannot replay. */
+/** Reject obsolete v0 event records before a live writer persists them. */
 function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void {
   const legacyType: string = 'request/header-delta'
   const legacy = events.find(event => event.type === legacyType)
@@ -289,289 +211,31 @@ function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId):
   }
 }
 
-/** Return an object record without widening arrays into message payloads. */
-function asRecord(value: unknown): Record<string, unknown> | undefined {
-  return typeof value === 'object' && value !== null && !Array.isArray(value)
-    ? value as Record<string, unknown>
-    : undefined
-}
-
-/** Whether a record contains every required key and no key outside the optional extension set. */
-function hasOnlyKeys(
-  record: Record<string, unknown>,
-  required: readonly string[],
-  optional: readonly string[] = [],
-): boolean {
-  const allowed = [...required, ...optional]
-  return Object.keys(record).every(key => allowed.includes(key))
-    && required.every(key => Object.hasOwn(record, key))
-}
-
-type PersistedMessageId = SessionEvent<'user/message'>['data']['id']
-
-/** Mint the stable import identity for a message persisted before identities existed. */
-function legacyMessageId(id: SessionId, seq: number): PersistedMessageId {
-  return `legacy-message:${id}:${seq}` as PersistedMessageId
-}
-
-/** Read a replacement target while leaving malformed surface metadata to the session validator. */
-function replacementStart(event: SessionEvent): number | undefined {
-  const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp)
-  return op?.['op'] === 'replace' && typeof op['start'] === 'number'
-    ? op['start']
-    : undefined
-}
-
-/** Whether one suffix event needs facts available only from the preceding stored prefix. */
-function needsLegacyPrefix(event: SessionEvent): boolean {
-  const data = asRecord(event.data)
-  const legacySteeringType: string = 'steering/message'
-  if (event.type === legacySteeringType) return true
-  if (data === undefined) return false
-  switch (event.type) {
-    case 'user/message':
-      return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content')
-    case 'assistant/message':
-      return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content')
-    case 'tool/result':
-      return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId')
-    default:
-      return false
-  }
-}
-
-/** Upgrade the removed steering surface event into its current user-message equivalent. */
-function migrateLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent {
-  const legacyType: string = 'steering/message'
-  if (event.type !== legacyType) return event
-  const data = asRecord(event.data)
-  if (data === undefined) {
-    throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`)
-  }
-  const wrapped = asRecord(data['message'])
-  if (wrapped !== undefined && Number.isSafeInteger(data['turn'])
-    && hasOnlyKeys(data, ['turn', 'message'])) {
-    return { ...event, type: 'user/message', data: wrapped } as SessionEvent
-  }
-  if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) {
-    throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`)
-  }
-  const { turn: _turn, ...message } = data
-  return {
-    ...event,
-    type: 'user/message',
-    data: {
-      ...message,
-      id: legacyMessageId(id, event.seq),
-      role: 'user',
-    },
-  } as SessionEvent
-}
-
-/** Remove the obsolete trigger after verifying the complete old turn-start envelope. */
-function migrateLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent {
-  if (event.type !== 'turn/start') return event
-  const data = asRecord(event.data)
-  if (data === undefined || !Object.hasOwn(data, 'trigger')) return event
-  const trigger = asRecord(data['trigger'])
-  if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1
-    || !hasOnlyKeys(data, ['turn', 'trigger'])
-    || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) {
-    throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`)
-  }
-  return { ...event, data: { turn: data['turn'] } } as SessionEvent
+/** Materialize one decoded event read and observe its physical EOF metadata. */
+async function collectDecodedEvents<TornMarker>(
+  read: DecodedSession<TornMarker>,
+): Promise<{ events: SessionEvent[]; tornMarker: TornMarker | undefined }> {
+  const events: SessionEvent[] = []
+  try {
+    for await (const event of read.events) events.push(event)
+  } catch (error: unknown) {
+    await read.completed.catch(() => undefined)
+    throw error
+  }
+  const { tornMarker } = await read.completed
+  return { events, tornMarker }
 }
 
-/** Upgrade an obsolete turn ending while preserving the latest-master envelope. */
-function migrateLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent {
-  if (event.type !== 'turn/end') return event
-  const data = asRecord(event.data)
-  /* v8 ignore next -- a non-record current envelope cannot match a legacy shape. */
-  if (data === undefined) return event
-  const malformed = (): never => {
-    throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`)
-  }
-  const reason = asRecord(data['reason'])
-  if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1
-    || !hasOnlyKeys(data, ['turn', 'reason'])
-    || reason === undefined || typeof reason['kind'] !== 'string') return malformed()
-
-  let currentReason: Record<string, unknown> | undefined
-  switch (reason['kind']) {
-    case 'completed':
-    case 'blocked':
-    case 'max-tokens':
-    case 'interrupted':
-      if (!hasOnlyKeys(reason, ['kind'])) return malformed()
-      return event
-    case 'aborted':
-      if (Object.hasOwn(reason, 'reason')) return event
-      if (!hasOnlyKeys(reason, ['kind'])) return malformed()
-      currentReason = { kind: 'aborted', reason: { kind: 'legacy' } }
-      break
-    case 'disposed':
-      if (!hasOnlyKeys(reason, ['kind'])) return malformed()
-      currentReason = { kind: 'aborted', reason: { kind: 'disposed' } }
-      break
-    case 'error': {
-      if (Object.hasOwn(reason, 'error')) return event
-      if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed()
-      const failure = asRecord(reason['failure'])
-      if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure'])
-        && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId'])
-        && typeof failure['message'] === 'string' && typeof failure['code'] === 'string'
-        && (failure['status'] === undefined || typeof failure['status'] === 'number')
-        && (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number')
-        && (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) {
-        currentReason = { kind: 'error', error: failure }
-        break
-      }
-      const messageKeys = reason['code'] === undefined
-        ? ['kind', 'step', 'message']
-        : ['kind', 'step', 'message', 'code']
-      if (!hasOnlyKeys(reason, messageKeys)
-        || typeof reason['message'] !== 'string'
-        || (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed()
-      currentReason = {
-        kind: 'error',
-        error: {
-          message: reason['message'],
-          code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN',
-        },
-      }
-      break
-    }
-    default:
-      return event
-  }
-
+/** Yield an immutable event array as one replacement stream. */
+function eventStream(events: readonly SessionEvent[]): AsyncIterable<SessionEvent> {
   return {
-    ...event,
-    data: {
-      ...data,
-      reason: currentReason,
+    [Symbol.asyncIterator]() {
+      const iterator = events[Symbol.iterator]()
+      return { next: () => Promise.resolve(iterator.next()) }
     },
-  } as SessionEvent
-}
-
-/**
- * Upgrade one pre-identity message event into the current wrapper shape.
- * Current-looking malformed events remain untouched so validation rejects them
- * instead of disguising corruption as legacy data.
- */
-function migrateLegacyMessageEvent(
-  event: SessionEvent,
-  id: SessionId,
-  messageIds: ReadonlyMap<number, PersistedMessageId>,
-): SessionEvent {
-  const data = asRecord(event.data)
-  if (data === undefined) return event
-  switch (event.type) {
-    case 'user/message': {
-      if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role')
-        || Object.hasOwn(data, 'message')
-        || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event
-      return {
-        ...event,
-        data: {
-          ...data,
-          id: legacyMessageId(id, event.seq),
-          role: 'user',
-        },
-      } as SessionEvent
-    }
-    case 'assistant/message': {
-      if (Object.hasOwn(data, 'message')
-        || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event
-      const { content, provenance, ...eventData } = data
-      return {
-        ...event,
-        data: {
-          ...eventData,
-          message: {
-            id: legacyMessageId(id, event.seq),
-            role: 'assistant',
-            content,
-            source: {
-              ...asRecord(provenance),
-              kind: 'model',
-            },
-          },
-        },
-      } as SessionEvent
-    }
-    case 'tool/result': {
-      if (Object.hasOwn(data, 'message')
-        || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content')
-        || !Object.hasOwn(data, 'isError')) return event
-      const { callId, content, isError, ...eventData } = data
-      const inheritedId = replacementStart(event)
-      return {
-        ...event,
-        data: {
-          ...eventData,
-          message: {
-            id: inheritedId === undefined
-              ? legacyMessageId(id, event.seq)
-              : messageIds.get(inheritedId),
-            role: 'user',
-            content: [{
-              type: 'tool-result',
-              toolCallId: callId,
-              content,
-              isError,
-            }],
-            source: {
-              kind: 'tool',
-              callId,
-            },
-          },
-        },
-      } as SessionEvent
-    }
-    default:
-      return event
   }
 }
 
-/** Read the identified message carried by one validated current event. */
-function eventMessageId(event: SessionEvent): PersistedMessageId | undefined {
-  const data = asRecord(event.data)
-  const message = event.type === 'user/message' ? data : asRecord(data?.['message'])
-  return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined
-}
-
-/** Materialize stored events as upgraded, validated snapshots with immutable messages. */
-function snapshotStoredEvents(events: readonly SessionEvent[], id: SessionId): SessionEvent[] {
-  assertSupportedEvents(events, id)
-  const messageIds = new Map<number, PersistedMessageId>()
-  return events.map((event) => {
-    const migratedStart = migrateLegacyTurnStartEvent(event, id)
-    const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id)
-    const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id)
-    const snapshot = snapshotSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds))
-    const messageId = eventMessageId(snapshot)
-    if (messageId !== undefined) messageIds.set(snapshot.seq, messageId)
-    return snapshot
-  })
-}
-
-/** Upgrade and validate an exclusively owned backend result without copying it. */
-function adoptStoredEvents(events: SessionEvent[], id: SessionId): SessionEvent[] {
-  assertSupportedEvents(events, id)
-  const messageIds = new Map<number, PersistedMessageId>()
-  for (const [index, event] of events.entries()) {
-    const migratedStart = migrateLegacyTurnStartEvent(event, id)
-    const migratedTurn = migrateLegacyTurnEndEvent(migratedStart, id)
-    const migratedSteering = migrateLegacySteeringEvent(migratedTurn, id)
-    const adopted = adoptSessionEvent(migrateLegacyMessageEvent(migratedSteering, id, messageIds))
-    events[index] = adopted
-    const messageId = eventMessageId(adopted)
-    if (messageId !== undefined) messageIds.set(adopted.seq, messageId)
-  }
-  return events
-}
-
 /**
  * Owns the backend-agnostic session write-path orchestration. A backend
  * constructs one (`new PersistenceCoordinator(ctx, this)`), implements
@@ -650,7 +314,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
     // A persisted artifact under this id (in ANY scope) blocks creation: load/
     // resume identify a session by id alone, so a second artifact would make
     // resume nondeterministic.
-    if (await this.backend.loadStored(meta.id) !== undefined) {
+    if (await this.backend.openStored(meta.id) !== undefined) {
       throw new Error(`session "${meta.id}" already has a persisted log on disk; load/resume it instead of creating`)
     }
     // Pure lazy: record intent only. No artifact until the first append.
@@ -821,9 +485,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
   /**
    * Read the stored events from `fromSeq` onward, detached and non-mutating
    * (the read-from-seq primitive behind the service's `readFrom`). Runs on
-   * the same per-id chain as writes; a backend with the seek-capable
-   * {@link PersistenceBackend.loadStoredFrom} hook reads only the suffix,
-   * every other backend reads its stored prefix and skips forward here.
+   * the same per-id chain as writes. The format decoder requests a backend
+   * suffix only when every selected transform can start at `fromSeq`.
    * @param id - persisted session to read.
    * @param fromSeq - first event seq to include; a non-negative safe integer.
    * @param signal - optional cancellation for queued and backend read work.
@@ -843,90 +506,64 @@ export class PersistenceCoordinator<TornMarker = unknown> {
     fromSeq: number,
     signal?: AbortSignal,
   ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
-    signal?.throwIfAborted()
-    if (this.backend.loadStoredFrom !== undefined) {
-      let suffix: StoredSuffix | undefined
+    for (;;) {
+      signal?.throwIfAborted()
+      const stored = await this.backend.openStored(id, signal)
+      signal?.throwIfAborted()
+      if (stored === undefined) throw new Error(`session "${id}" not found`)
       try {
-        suffix = await this.backend.loadStoredFrom(id, fromSeq, signal)
+        const current = decodeStoredSession(stored, id, fromSeq)
+        const { events } = await collectDecodedEvents(current)
+        signal?.throwIfAborted()
+        return { meta: structuredClone(current.meta), events }
       } catch (error: unknown) {
-        if (signal?.aborted) signal.throwIfAborted()
+        signal?.throwIfAborted()
+        if (error instanceof SessionPersistenceRevisionConflictError) continue
         throw error
       }
-      signal?.throwIfAborted()
-      if (suffix === undefined) throw new Error(`session "${id}" not found`)
-      this.assertStoredId(id, suffix.meta)
-      this.assertVersion(suffix.meta)
-      if (suffix.events.some(needsLegacyPrefix)) {
-        const whole = await this.readStoredPrefix(id, signal)
-        return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) }
-      }
-      const events = snapshotStoredEvents(suffix.events, id)
-      this.assertEventsSupported(suffix.meta, events)
-      return { meta: structuredClone(suffix.meta), events }
-    }
-    const whole = await this.readStoredPrefix(id, signal)
-    // Sequential fallback: contiguous seqs from 0 make the suffix an index slice.
-    return { meta: whole.meta, events: whole.events.slice(fromSeq) }
-  }
-
-  /** Read one detached physical prefix without logical recovery or caching. */
-  private async readStoredPrefix(
-    id: SessionId,
-    signal?: AbortSignal,
-  ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
-    signal?.throwIfAborted()
-    const stored = await this.backend.loadStored(id, signal)
-    signal?.throwIfAborted()
-    if (stored === undefined) throw new Error(`session "${id}" not found`)
-    this.assertStoredId(id, stored.meta)
-    this.assertVersion(stored.meta)
-    const events = snapshotStoredEvents(stored.events, id)
-    this.assertEventsSupported(stored.meta, events)
-    return {
-      meta: structuredClone(stored.meta),
-      events,
     }
   }
 
   /** Read, repair in memory, validate, and freeze one cold source once. */
   private async prepareCore(id: SessionId): Promise<PreparedSessionSource<TornMarker>> {
-    const stored = await this.backend.loadStored(id)
-    if (stored === undefined) throw new Error(`session "${id}" not found`)
-    try {
-      const { meta, events, revision, tornMarker } = stored
-      this.assertStoredId(id, meta)
-      this.assertVersion(meta)
-      const storedEvents = adoptStoredEvents(events, id)
-      this.assertEventsSupported(meta, storedEvents)
-
-      // Preserve complete interrupted events and synthesize only missing closers.
-      const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent)
-      const balanced = [...storedEvents, ...closers]
-      const session = this.ctx.sessions.prepare(id, {
-        seed: balanced,
-        meta,
-        seedSource: 'persistence',
-      })
-      const inspection: SessionInspection = Object.freeze({
-        meta: session.header,
-        events: Object.freeze(balanced),
-      })
-      return {
-        inspection,
-        session,
-        revision,
-        sessionLength: session.events.length,
-        tornMarker,
-        closers,
+    for (;;) {
+      const stored = await this.backend.openStored(id)
+      if (stored === undefined) throw new Error(`session "${id}" not found`)
+      try {
+        const current = decodeStoredSession(stored, id)
+        const { events: storedEvents, tornMarker } = await collectDecodedEvents(current)
+
+        // Preserve complete interrupted events and synthesize only missing closers.
+        const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent)
+        const balanced = [...storedEvents, ...closers]
+        const session = this.ctx.sessions.prepare(id, {
+          seed: balanced,
+          meta: current.meta,
+          seedSource: 'persistence',
+        })
+        const inspection: SessionInspection = Object.freeze({
+          meta: session.header,
+          events: Object.freeze(balanced),
+        })
+        return {
+          inspection,
+          session,
+          revision: current.revision,
+          sourceVersion: current.sourceVersion,
+          sessionLength: session.events.length,
+          tornMarker,
+          closers,
+        }
+      } catch (error: unknown) {
+        if (error instanceof SessionPersistenceRevisionConflictError) continue
+        // An unsupported format is a refusal over an intact log, not damage —
+        // surface it unwrapped so callers can point at the raw artifact.
+        if (error instanceof SessionFormatUnsupportedError) throw error
+        throw new SessionPersistenceCorruptionError(
+          `stored session "${id}" failed validation: ${String(error)}`,
+          { cause: error },
+        )
       }
-    } catch (error: unknown) {
-      // An unsupported format is a refusal over an intact log, not damage —
-      // surface it unwrapped so callers can point at the raw artifact.
-      if (error instanceof SessionFormatUnsupportedError) throw error
-      throw new SessionPersistenceCorruptionError(
-        `stored session "${id}" failed validation: ${String(error)}`,
-        { cause: error },
-      )
     }
   }
 
@@ -941,6 +578,19 @@ export class PersistenceCoordinator<TornMarker = unknown> {
       throw new Error(`session "${id}" already has a live persistence owner`)
     }
     if (!await this.isPreparedSourceCurrent(source)) return undefined
+    if (source.sourceVersion !== SESSION_FORMAT_VERSION) {
+      try {
+        await this.backend.replaceStored(
+          source.revision,
+          source.inspection.meta,
+          eventStream(source.inspection.events),
+        )
+      } catch (error: unknown) {
+        if (!(error instanceof SessionPersistenceRevisionConflictError)) throw error
+      }
+      // A commit has a new revision; a conflict names a different source.
+      return undefined
+    }
     if (source.tornMarker !== undefined || source.closers.length > 0) {
       await this.backend.commitRepair(source.inspection.meta, source.tornMarker, source.closers)
       // The repair changed the durable revision. Reload the exact committed
@@ -1043,44 +693,6 @@ export class PersistenceCoordinator<TornMarker = unknown> {
     }
   }
 
-  private assertVersion(meta: SessionHeader): void {
-    if (meta.version === SESSION_FORMAT_VERSION) return
-    throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version))
-  }
-
-  /**
-   * Refuse a log containing an event type this build does not know, unless the
-   * writer marked the event ignorable: an unrecognized required event may
-   * change how the rest of the log must be interpreted, so silently skipping
-   * it would reconstruct a wrong session (the envelope contract on
-   * `SessionEvent.ignorable`). Runs on NORMALIZED events — after
-   * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes
-   * this build still reads and rejected the ones it does not, so those keep
-   * their specific diagnostics.
-   */
-  private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
-    for (const event of events) {
-      if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
-      throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
-    }
-  }
-
-  /** Build a format refusal that points at the raw artifact when the backend has one. */
-  private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError {
-    const location = this.backend.locate?.(meta)
-    return new SessionFormatUnsupportedError(
-      location === undefined ? reason : `${reason} (raw log: ${location.path})`,
-      location,
-    )
-  }
-
-  /** Reject backend metadata that is not bound to the requested session id. */
-  private assertStoredId(id: SessionId, meta: SessionHeader): void {
-    if (meta.id !== id) {
-      throw new Error(`stored session identity mismatch: requested "${id}", header contains "${meta.id}"`)
-    }
-  }
-
   // --- write path (session/event → flush drain) ---
 
   private installWritePath(): void {
@@ -1213,11 +825,19 @@ export class PersistenceCoordinator<TornMarker = unknown> {
    */
   private async seedMatchesPersisted(id: SessionId, seed: readonly SessionEvent[], cursor: number): Promise<boolean> {
     if (cursor === 0) return true
-    const stored = await this.backend.loadStored(id)
-    /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */
-    if (stored === undefined) return false
-    this.assertStoredId(id, stored.meta)
-    return seedCoversPrefix(seed, snapshotStoredEvents(stored.events, id).slice(0, cursor))
+    for (;;) {
+      const stored = await this.backend.openStored(id)
+      /* v8 ignore next -- a cursor > 0 means the session was materialized, so it exists */
+      if (stored === undefined) return false
+      try {
+        const current = decodeStoredSession(stored, id)
+        const { events } = await collectDecodedEvents(current)
+        return seedCoversPrefix(seed, events.slice(0, cursor))
+      } catch (error: unknown) {
+        if (error instanceof SessionPersistenceRevisionConflictError) continue
+        throw error
+      }
+    }
   }
 
   /**
@@ -1271,13 +891,18 @@ export class PersistenceCoordinator<TornMarker = unknown> {
 
     // case 2/3: resolve the id once across storage, then let adoption reject a
     // cwd mismatch before repair or state publication.
-    const live = await this.backend.loadStored(id)
-    if (live !== undefined) {
+    for (;;) {
+      const live = await this.backend.openStored(id)
+      if (live === undefined) break
       // Do NOT route through cold preparation: that crash-repairs open turns as
       // interrupted, which is wrong for HMR while the live Session is still the
       // authority and may append the real step/turn end later.
-      await this.adoptLivePrefix(session, seed, live)
-      return
+      try {
+        if (await this.adoptLivePrefix(session, seed, live)) return
+      } catch (error: unknown) {
+        if (error instanceof SessionPersistenceRevisionConflictError) continue
+        throw error
+      }
     }
 
     // case 4: a genuinely new session. Register its meta (lazy), then persist its
@@ -1298,28 +923,39 @@ export class PersistenceCoordinator<TornMarker = unknown> {
    * the live Session is still the authority), bind ownership, and persist the
    * live suffix that was ahead of the stored prefix.
    */
-  private async adoptLivePrefix(session: Session, seed: readonly SessionEvent[], stored: StoredPrefix<TornMarker>): Promise<void> {
-    const { meta, events, tornMarker } = stored
-    this.assertStoredId(session.header.id, meta)
-    if (meta.cwd !== session.header.cwd) {
-      throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
+  private async adoptLivePrefix(
+    session: Session,
+    seed: readonly SessionEvent[],
+    stored: StoredSessionSource<TornMarker>,
+  ): Promise<boolean> {
+    const current = decodeStoredSession(stored, session.header.id)
+    if (current.meta.cwd !== session.header.cwd) {
+      throw new Error(`session "${session.header.id}" is already persisted at a different cwd (persisted: ${String(current.meta.cwd)}, live: ${String(session.header.cwd)}) (id collision)`)
     }
-    this.assertVersion(meta)
-    const storedEvents = snapshotStoredEvents(events, session.header.id)
-    this.assertEventsSupported(meta, storedEvents)
+    const { events: storedEvents, tornMarker } = await collectDecodedEvents(current)
     if (!seedCoversPrefix(seed, storedEvents)) {
       throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
     }
+    if (current.sourceVersion !== SESSION_FORMAT_VERSION) {
+      await this.backend.replaceStored(
+        current.revision,
+        current.meta,
+        eventStream(storedEvents),
+      )
+      // Reopen after the commit because it produced a new source revision.
+      return false
+    }
     // Truncate-only repair (no closers): the open turn is NOT closed here.
-    if (tornMarker !== undefined) await this.backend.commitRepair(meta, tornMarker, [])
+    if (tornMarker !== undefined) await this.backend.commitRepair(current.meta, tornMarker, [])
     this.states.set(session.header.id, {
-      meta: { ...meta },
+      meta: { ...current.meta },
       cursor: storedEvents.length,
       materialized: true,
       owner: session,
     })
     const suffix = seed.slice(storedEvents.length)
     if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
+    return true
   }
 
   private async flush(session: Session): Promise<void> {

+ 480 - 0
packages/session/session-persistence/src/format-decoder.ts

@@ -0,0 +1,480 @@
+/**
+ * Static Session format decoding from backend-owned JSON records to the
+ * current durable header and event types.
+ * @module @deepseek-ai/dsh-session-persistence/format-decoder
+ */
+
+import {
+  adoptSessionEvent,
+  KNOWN_SESSION_EVENT_TYPES,
+  SESSION_FORMAT_VERSION,
+  Session,
+  SessionId,
+  snapshotJsonValue,
+} from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import {
+  unversionedFormatCompatibility,
+} from './format-v0-compat.ts'
+import type { UnversionedFormatCompatibility } from './format-v0-compat.ts'
+import type { SessionLocation } from './index.ts'
+import { SESSION_FORMAT_STEPS } from './format-migrations/index.ts'
+import type { SessionPersistenceRevision } from './revision.ts'
+
+/** Stable facts available to one format step invocation. */
+export interface SessionFormatContext {
+  /** Session identity read from the source header. */
+  readonly sessionId: SessionId
+}
+
+/** One static adjacent-version transform in the durable format decoder. */
+export interface SessionFormatStep {
+  /** Input Session format version. */
+  readonly from: number
+  /** Output Session format version; must equal `from + 1`. */
+  readonly to: number
+  /**
+   * Transform and validate the header fields understood by this step.
+   * @param meta - detached input header for {@link from}.
+   * @param context - stable session identity.
+   * @returns detached header JSON carrying {@link to}.
+   */
+  migrateHeader(meta: unknown, context: SessionFormatContext): unknown
+  /**
+   * Lazily transform and validate events understood by this step.
+   * @param events - detached input records in durable sequence order.
+   * @param context - stable session identity.
+   * @returns a lazy output stream in the next format.
+   */
+  migrateEvents(events: AsyncIterable<unknown>, context: SessionFormatContext): AsyncIterable<unknown>
+}
+
+/** Options for one physical event read. */
+export interface StoredEventReadOptions {
+  /** First physical event sequence to request. */
+  readonly fromSeq?: number
+}
+
+/** Completion metadata produced after a physical event stream reaches EOF. */
+export interface StoredEventReadCompletion<TornMarker> {
+  /** Backend-owned token for a recoverable physical tail. */
+  readonly tornMarker?: TornMarker
+}
+
+/** One revision-bound physical event stream. */
+export interface StoredEventRead<TornMarker> {
+  /** Parsed JSON records from the exact source revision. */
+  readonly events: AsyncIterable<unknown>
+  /** Resolves only after the stream reaches EOF at the same revision. */
+  readonly completed: Promise<StoredEventReadCompletion<TornMarker>>
+}
+
+/** Repeatable access to one stored header and exact durable revision. */
+export interface StoredSessionSource<TornMarker> {
+  /** Parsed header JSON; format validation belongs to the decoder. */
+  readonly meta: unknown
+  /** Exact backend revision every event read must reproduce or reject. */
+  readonly revision: SessionPersistenceRevision
+  /** Raw artifact location used to enrich unsupported-format diagnostics. */
+  readonly location?: SessionLocation
+  /**
+   * Open a new event read bound to {@link revision}. A concurrent replacement
+   * rejects the read instead of returning events from another revision.
+   * @param options - optional suffix request.
+   * @returns one independently consumable physical event read.
+   */
+  readEvents(options?: StoredEventReadOptions): StoredEventRead<TornMarker>
+}
+
+/** One decoded current-format read bound to an exact stored revision. */
+export interface DecodedSession<TornMarker> {
+  /** Validated current-format header. */
+  readonly meta: SessionHeader
+  /** Version observed before any format step ran. */
+  readonly sourceVersion: number
+  /** Exact backend revision represented by this source. */
+  readonly revision: SessionPersistenceRevision
+  /** Validated current-format events at or past the requested sequence. */
+  readonly events: AsyncIterable<SessionEvent>
+  /** Completion metadata from the physical read supplying the events. */
+  readonly completed: Promise<StoredEventReadCompletion<TornMarker>>
+}
+
+/**
+ * The stored log is intact but this runtime cannot faithfully interpret its
+ * format version or required event vocabulary.
+ */
+export class SessionFormatUnsupportedError extends Error {
+  /**
+   * @param message - stable refusal reason, including the raw location when available.
+   * @param location - backend artifact location when one exists.
+   */
+  constructor(message: string, readonly location?: SessionLocation) {
+    super(message)
+    this.name = 'SessionFormatUnsupportedError'
+  }
+}
+
+/**
+ * Direction-aware refusal text for a stored format version this build cannot
+ * decode.
+ * @param id - stored session identity.
+ * @param version - stored format version.
+ * @returns stable refusal text without a raw-location suffix.
+ */
+export function sessionFormatVersionRefusal(id: string, version: number): string {
+  return version > SESSION_FORMAT_VERSION
+    ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`
+    : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`
+}
+
+function buildStepIndex(steps: readonly SessionFormatStep[]): ReadonlyMap<number, SessionFormatStep> {
+  const byFrom = new Map<number, SessionFormatStep>()
+  for (const step of steps) {
+    if (!Number.isSafeInteger(step.from) || step.from < 0 || step.to !== step.from + 1) {
+      throw new TypeError(`Session format step must be an adjacent non-negative version, got v${step.from} -> v${step.to}`)
+    }
+    if (byFrom.has(step.from)) {
+      throw new TypeError(`duplicate Session format step from v${step.from}`)
+    }
+    if (step.to > SESSION_FORMAT_VERSION) {
+      throw new TypeError(`Session format step v${step.from} -> v${step.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`)
+    }
+    byFrom.set(step.from, step)
+  }
+  for (const first of byFrom.values()) {
+    for (let version = first.from; version < SESSION_FORMAT_VERSION; version++) {
+      if (!byFrom.has(version)) {
+        throw new TypeError(
+          `Session format registry has an incomplete path from v${first.from} to v${SESSION_FORMAT_VERSION}: missing v${version} -> v${version + 1}`,
+        )
+      }
+    }
+  }
+  return byFrom
+}
+
+const STEP_BY_FROM = buildStepIndex(SESSION_FORMAT_STEPS)
+
+interface DecodedHeader {
+  readonly meta: SessionHeader
+  readonly sourceVersion: number
+  readonly steps: readonly SessionFormatStep[]
+  readonly unversionedCompatibility?: UnversionedFormatCompatibility
+}
+
+function asRecord(value: unknown): Record<string, unknown> | undefined {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : undefined
+}
+
+interface StoredHeaderSource {
+  readonly meta: unknown
+  readonly location?: SessionLocation
+}
+
+function unsupported(
+  source: StoredHeaderSource,
+  reason: string,
+): SessionFormatUnsupportedError {
+  const location = source.location
+  return new SessionFormatUnsupportedError(
+    location === undefined ? reason : `${reason} (raw log: ${location.path})`,
+    location,
+  )
+}
+
+function readSourceHeader(
+  source: StoredHeaderSource,
+  expectedId: SessionId,
+): { meta: Record<string, unknown>; version: number; id: SessionId } {
+  const snapshot = snapshotJsonValue(source.meta)
+  const meta = asRecord(snapshot)
+  if (meta === undefined) throw new Error('stored session header is not a lossless JSON record')
+  if (!Number.isSafeInteger(meta['version'])) {
+    throw new Error(`stored session header has invalid format version ${String(meta['version'])}`)
+  }
+  const version = meta['version'] as number
+  if (version > SESSION_FORMAT_VERSION) {
+    throw unsupported(source, sessionFormatVersionRefusal(String(meta['id']), version))
+  }
+  if (typeof meta['id'] !== 'string') throw new Error('stored session header has no string id')
+  const id = SessionId(meta['id'])
+  if (id !== expectedId) {
+    throw new Error(`stored session identity mismatch: requested "${expectedId}", header contains "${id}"`)
+  }
+  return { meta, version, id }
+}
+
+function planSteps(
+  source: StoredHeaderSource,
+  id: SessionId,
+  fromVersion: number,
+): readonly SessionFormatStep[] {
+  const steps: SessionFormatStep[] = []
+  for (let version = fromVersion; version < SESSION_FORMAT_VERSION; version++) {
+    const step = STEP_BY_FROM.get(version)
+    if (step === undefined) {
+      throw unsupported(
+        source,
+        `session "${id}" uses log format v${fromVersion}, older than the supported v${SESSION_FORMAT_VERSION}, and this build has no upgrade path to it: missing v${version} -> v${version + 1}`,
+      )
+    }
+    steps.push(step)
+  }
+  return steps
+}
+
+function decodeHeader(
+  source: StoredHeaderSource,
+  expectedId: SessionId,
+): DecodedHeader {
+  const stored = readSourceHeader(source, expectedId)
+  const steps = planSteps(source, stored.id, stored.version)
+  let meta: unknown = stored.meta
+  for (const step of steps) {
+    const context: SessionFormatContext = { sessionId: stored.id }
+    try {
+      meta = snapshotJsonValue(step.migrateHeader(meta, context))
+    } catch (error: unknown) {
+      throw new Error(
+        `session "${stored.id}" header migration v${step.from} -> v${step.to} failed`,
+        { cause: error },
+      )
+    }
+    const record = asRecord(meta)
+    const actual = record?.['version']
+    if (actual !== step.to) {
+      throw new Error(`Session format step v${step.from} -> v${step.to} returned header version ${String(actual)}`)
+    }
+    if (record === undefined
+      || record['id'] !== stored.id
+      || record['cwd'] !== stored.meta['cwd']) {
+      throw new Error(`Session format step v${step.from} -> v${step.to} changed session storage identity`)
+    }
+  }
+  const current = Session.create(stored.id, undefined, meta as SessionHeader).header
+  const compatibility = unversionedFormatCompatibility(stored.version)
+  return {
+    meta: current,
+    sourceVersion: stored.version,
+    steps,
+    ...(compatibility === undefined ? {} : { unversionedCompatibility: compatibility }),
+  }
+}
+
+/**
+ * Decode one stored header without opening its event log. Listing uses the
+ * same static format path as full Session reads.
+ * @param meta - parsed backend header JSON.
+ * @param expectedId - identity selected by the backend or caller.
+ * @param location - optional raw artifact location for refusal diagnostics.
+ * @returns the validated current-format header.
+ */
+export function decodeStoredSessionHeader(
+  meta: unknown,
+  expectedId: SessionId,
+  location?: SessionLocation,
+): SessionHeader {
+  return decodeHeader({ meta, ...location === undefined ? {} : { location } }, expectedId).meta
+}
+
+function assertCurrentEnvelope(value: unknown, id: SessionId): SessionEvent {
+  const snapshot = snapshotJsonValue(value)
+  const event = asRecord(snapshot)
+  if (event === undefined) throw new Error(`session "${id}" contains a non-record event`)
+  if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`)
+  if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) {
+    throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`)
+  }
+  if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) {
+    throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`)
+  }
+  if (!Object.hasOwn(event, 'data')) {
+    throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`)
+  }
+  return event as unknown as SessionEvent
+}
+
+function assertCurrentEventSupported<TornMarker>(
+  source: StoredSessionSource<TornMarker>,
+  meta: SessionHeader,
+  event: SessionEvent,
+): void {
+  const legacyType: string = 'request/header-delta'
+  if (event.type === legacyType) {
+    throw new Error(`session "${meta.id}" contains unsupported legacy request/header-delta event at seq ${event.seq}`)
+  }
+  const legacyModeType: string = 'mode/set'
+  if (event.type === legacyModeType) {
+    throw new Error(`session "${meta.id}" contains unsupported legacy mode/set event at seq ${event.seq}`)
+  }
+  if (event.type === 'request/header'
+    && (event.data as { reason?: string }).reason === 'fallback') {
+    throw new Error(`session "${meta.id}" contains unsupported legacy request/header reason "fallback" at seq ${event.seq}`)
+  }
+  if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) return
+  throw unsupported(
+    source,
+    `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`,
+  )
+}
+
+async function* decodeCurrentEvents<TornMarker>(
+  source: StoredSessionSource<TornMarker>,
+  meta: SessionHeader,
+  events: AsyncIterable<unknown>,
+  expectedSeq: number,
+): AsyncIterable<SessionEvent> {
+  let nextSeq = expectedSeq
+  for await (const raw of events) {
+    const event = assertCurrentEnvelope(raw, meta.id)
+    if (event.seq !== nextSeq) {
+      throw new Error(`session "${meta.id}" event seq mismatch: expected ${nextSeq}, got ${event.seq}`)
+    }
+    const current = adoptSessionEvent(event)
+    assertCurrentEventSupported(source, meta, current)
+    nextSeq += 1
+    yield current
+  }
+}
+
+function transformEvents(
+  events: AsyncIterable<unknown>,
+  steps: readonly SessionFormatStep[],
+  id: SessionId,
+): AsyncIterable<unknown> {
+  let transformed = events
+  for (const step of steps) {
+    const input = transformed
+    const context: SessionFormatContext = { sessionId: id }
+    transformed = (async function* (): AsyncIterable<unknown> {
+      try {
+        yield* step.migrateEvents(input, context)
+      } catch (error: unknown) {
+        throw new Error(
+          `session "${id}" event migration v${step.from} -> v${step.to} failed`,
+          { cause: error },
+        )
+      }
+    })()
+  }
+  return transformed
+}
+
+async function* snapshotStoredEvents(
+  events: AsyncIterable<unknown>,
+  id: SessionId,
+): AsyncIterable<unknown> {
+  for await (const event of events) {
+    const snapshot = snapshotJsonValue(event)
+    if (snapshot === undefined) {
+      throw new Error(`session "${id}" contains an event that is not losslessly JSON-serializable`)
+    }
+    yield snapshot
+  }
+}
+
+function deferred<T>(): {
+  readonly promise: Promise<T>
+  resolve(value: T): void
+  reject(reason: unknown): void
+} {
+  let resolve!: (value: T) => void
+  let reject!: (reason: unknown) => void
+  const promise = new Promise<T>((accept, decline) => {
+    resolve = accept
+    reject = decline
+  })
+  return { promise, resolve, reject }
+}
+
+function decodedRead<TornMarker>(
+  source: StoredSessionSource<TornMarker>,
+  header: DecodedHeader,
+  requestedFromSeq: number,
+): {
+  readonly events: AsyncIterable<SessionEvent>
+  readonly completed: Promise<StoredEventReadCompletion<TornMarker>>
+} {
+  const completion = deferred<StoredEventReadCompletion<TornMarker>>()
+  const migrating = header.steps.length > 0
+  const compatibility = header.unversionedCompatibility
+  let physical: StoredEventRead<TornMarker> | undefined
+
+  const events = (async function* (): AsyncIterable<SessionEvent> {
+    try {
+      let physicalFromSeq = migrating ? 0 : requestedFromSeq
+      physical = source.readEvents({ fromSeq: physicalFromSeq })
+      void physical.completed.catch(() => undefined)
+      let raw: AsyncIterable<unknown> = physical.events
+      let physicalCompletion: StoredEventReadCompletion<TornMarker> | undefined
+
+      if (!migrating && requestedFromSeq > 0 && compatibility !== undefined) {
+        const suffix: unknown[] = []
+        for await (const value of raw) suffix.push(value)
+        physicalCompletion = await physical.completed
+        if (suffix.some(value => compatibility.requiresPrefix(value))) {
+          physicalFromSeq = 0
+          physical = source.readEvents({ fromSeq: 0 })
+          void physical.completed.catch(() => undefined)
+          raw = physical.events
+          physicalCompletion = undefined
+        } else {
+          raw = (async function* () {
+            for (const value of suffix) yield await Promise.resolve(value)
+          })()
+        }
+      }
+
+      const storedEvents = snapshotStoredEvents(raw, header.meta.id)
+      const canonicalEvents = compatibility === undefined
+        ? storedEvents
+        : compatibility.canonicalizeEvents(storedEvents, header.meta.id)
+      const transformed = transformEvents(
+        canonicalEvents,
+        header.steps,
+        header.meta.id,
+      )
+      const current = decodeCurrentEvents(source, header.meta, transformed, physicalFromSeq)
+      for await (const event of current) {
+        if (event.seq >= requestedFromSeq) yield event
+      }
+      completion.resolve(physicalCompletion ?? await physical.completed)
+    } catch (error: unknown) {
+      completion.reject(error)
+      throw error
+    }
+  })()
+
+  return { events, completed: completion.promise }
+}
+
+/**
+ * Decode one backend source through the static adjacent-version
+ * steps and the current header/event validators. Format selection is complete
+ * before any consumer-specific recovery runs.
+ * @param source - backend-owned header, revision, and event reader factory.
+ * @param expectedId - session identity selected by the caller.
+ * @param fromSeq - first current-format event sequence to return.
+ * @returns one decoded current-format stream bound to the stored revision.
+ */
+export function decodeStoredSession<TornMarker>(
+  source: StoredSessionSource<TornMarker>,
+  expectedId: SessionId,
+  fromSeq = 0,
+): DecodedSession<TornMarker> {
+  if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) {
+    throw new TypeError(`stored event fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`)
+  }
+  const header = decodeHeader(source, expectedId)
+  const read = decodedRead(source, header, fromSeq)
+  return {
+    meta: header.meta,
+    sourceVersion: header.sourceVersion,
+    revision: source.revision,
+    events: read.events,
+    completed: read.completed,
+  }
+}

+ 6 - 0
packages/session/session-persistence/src/format-migrations/index.ts

@@ -0,0 +1,6 @@
+/** Static adjacent-version Session format steps shipped by this build. */
+
+import type { SessionFormatStep } from '../format-decoder.ts'
+
+/** Ordered durable format steps; format v0 is current, so the chain is empty. */
+export const SESSION_FORMAT_STEPS: readonly SessionFormatStep[] = Object.freeze([])

+ 293 - 0
packages/session/session-persistence/src/format-v0-compat.ts

@@ -0,0 +1,293 @@
+/**
+ * Same-version normalization for durable format-v0 Session records.
+ * @module @deepseek-ai/dsh-session-persistence/format-v0-compat
+ */
+
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+
+/** One format-specific normalizer selected before adjacent-version migrations. */
+export interface UnversionedFormatCompatibility {
+  /** Header version whose historical records require this normalizer. */
+  readonly version: number
+  /**
+   * Whether converting one suffix record requires facts from earlier events.
+   * @param value - parsed event JSON from a suffix read.
+   * @returns whether the decoder must reopen the complete event stream.
+   */
+  requiresPrefix(value: unknown): boolean
+  /**
+   * Convert recognized historical records into the canonical representation
+   * carrying the same version number.
+   * @param events - parsed event JSON in durable sequence order.
+   * @param sessionId - identity read from the stored header.
+   * @returns a lazy stream in the canonical representation for {@link version}.
+   */
+  canonicalizeEvents(events: AsyncIterable<unknown>, sessionId: SessionId): AsyncIterable<unknown>
+}
+
+function asRecord(value: unknown): Record<string, unknown> | undefined {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+    ? value as Record<string, unknown>
+    : undefined
+}
+
+function hasOnlyKeys(
+  record: Record<string, unknown>,
+  required: readonly string[],
+  optional: readonly string[] = [],
+): boolean {
+  const allowed = [...required, ...optional]
+  return Object.keys(record).every(key => allowed.includes(key))
+    && required.every(key => Object.hasOwn(record, key))
+}
+
+type PersistedMessageId = SessionEvent<'user/message'>['data']['id']
+
+function legacyMessageId(id: SessionId, seq: number): PersistedMessageId {
+  return `legacy-message:${id}:${seq}` as PersistedMessageId
+}
+
+function replacementStart(event: SessionEvent): number | undefined {
+  const op = asRecord((event as SessionEvent & { surfaceOp?: unknown }).surfaceOp)
+  return op?.['op'] === 'replace' && typeof op['start'] === 'number'
+    ? op['start']
+    : undefined
+}
+
+function requiresV0Prefix(value: unknown): boolean {
+  const event = asRecord(value)
+  if (event === undefined) return false
+  const data = asRecord(event['data'])
+  if (event['type'] === 'steering/message') return true
+  if (data === undefined) return false
+  switch (event['type']) {
+    case 'user/message':
+      return !Object.hasOwn(data, 'id') && Object.hasOwn(data, 'content')
+    case 'assistant/message':
+      return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'content')
+    case 'tool/result':
+      return !Object.hasOwn(data, 'message') && Object.hasOwn(data, 'callId')
+    default:
+      return false
+  }
+}
+
+function readV0Event(value: unknown, id: SessionId): SessionEvent {
+  const event = asRecord(value)
+  if (event === undefined) throw new Error(`session "${id}" contains a non-record event`)
+  if (typeof event['type'] !== 'string') throw new Error(`session "${id}" contains an event without a string type`)
+  if (!Number.isSafeInteger(event['seq']) || (event['seq'] as number) < 0) {
+    throw new Error(`session "${id}" contains event type "${event['type']}" with invalid seq ${String(event['seq'])}`)
+  }
+  if (typeof event['time'] !== 'number' || !Number.isFinite(event['time'])) {
+    throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} with invalid time`)
+  }
+  if (!Object.hasOwn(event, 'data')) {
+    throw new Error(`session "${id}" contains event type "${event['type']}" at seq ${String(event['seq'])} without data`)
+  }
+  return event as unknown as SessionEvent
+}
+
+function canonicalizeLegacySteeringEvent(event: SessionEvent, id: SessionId): SessionEvent {
+  const legacyType: string = 'steering/message'
+  if (event.type !== legacyType) return event
+  const data = asRecord(event.data)
+  if (data === undefined) {
+    throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`)
+  }
+  const wrapped = asRecord(data['message'])
+  if (wrapped !== undefined && Number.isSafeInteger(data['turn'])
+    && hasOnlyKeys(data, ['turn', 'message'])) {
+    return { ...event, type: 'user/message', data: wrapped } as SessionEvent
+  }
+  if (!Number.isSafeInteger(data['turn']) || !hasOnlyKeys(data, ['turn', 'content', 'source'])) {
+    throw new Error(`session "${id}" contains malformed pre-react-loop steering/message at seq ${event.seq}`)
+  }
+  const { turn: _turn, ...message } = data
+  return {
+    ...event,
+    type: 'user/message',
+    data: { ...message, id: legacyMessageId(id, event.seq), role: 'user' },
+  } as SessionEvent
+}
+
+function canonicalizeLegacyTurnStartEvent(event: SessionEvent, id: SessionId): SessionEvent {
+  if (event.type !== 'turn/start') return event
+  const data = asRecord(event.data)
+  if (data === undefined || !Object.hasOwn(data, 'trigger')) return event
+  const trigger = asRecord(data['trigger'])
+  if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1
+    || !hasOnlyKeys(data, ['turn', 'trigger'])
+    || trigger === undefined || typeof trigger['kind'] !== 'string' || trigger['kind'].length === 0) {
+    throw new Error(`session "${id}" contains malformed pre-react-loop turn/start at seq ${event.seq}`)
+  }
+  return { ...event, data: { turn: data['turn'] } } as SessionEvent
+}
+
+function canonicalizeLegacyTurnEndEvent(event: SessionEvent, id: SessionId): SessionEvent {
+  if (event.type !== 'turn/end') return event
+  const data = asRecord(event.data)
+  if (data === undefined) return event
+  const malformed = (): never => {
+    throw new Error(`session "${id}" contains malformed pre-react-loop turn/end at seq ${event.seq}`)
+  }
+  const reason = asRecord(data['reason'])
+  if (!Number.isSafeInteger(data['turn']) || (data['turn'] as number) < 1
+    || !hasOnlyKeys(data, ['turn', 'reason'])
+    || reason === undefined || typeof reason['kind'] !== 'string') return malformed()
+
+  let currentReason: Record<string, unknown> | undefined
+  switch (reason['kind']) {
+    case 'completed':
+    case 'blocked':
+    case 'max-tokens':
+    case 'interrupted':
+      if (!hasOnlyKeys(reason, ['kind'])) return malformed()
+      return event
+    case 'aborted':
+      if (Object.hasOwn(reason, 'reason')) return event
+      if (!hasOnlyKeys(reason, ['kind'])) return malformed()
+      currentReason = { kind: 'aborted', reason: { kind: 'legacy' } }
+      break
+    case 'disposed':
+      if (!hasOnlyKeys(reason, ['kind'])) return malformed()
+      currentReason = { kind: 'aborted', reason: { kind: 'disposed' } }
+      break
+    case 'error': {
+      if (Object.hasOwn(reason, 'error')) return event
+      if (!Number.isSafeInteger(reason['step']) || (reason['step'] as number) < 0) return malformed()
+      const failure = asRecord(reason['failure'])
+      if (failure !== undefined && hasOnlyKeys(reason, ['kind', 'step', 'failure'])
+        && hasOnlyKeys(failure, ['message', 'code'], ['status', 'providerRetryAfterMs', 'requestId'])
+        && typeof failure['message'] === 'string' && typeof failure['code'] === 'string'
+        && (failure['status'] === undefined || typeof failure['status'] === 'number')
+        && (failure['providerRetryAfterMs'] === undefined || typeof failure['providerRetryAfterMs'] === 'number')
+        && (failure['requestId'] === undefined || typeof failure['requestId'] === 'string')) {
+        currentReason = { kind: 'error', error: failure }
+        break
+      }
+      const messageKeys = reason['code'] === undefined
+        ? ['kind', 'step', 'message']
+        : ['kind', 'step', 'message', 'code']
+      if (!hasOnlyKeys(reason, messageKeys)
+        || typeof reason['message'] !== 'string'
+        || (reason['code'] !== undefined && typeof reason['code'] !== 'string')) return malformed()
+      currentReason = {
+        kind: 'error',
+        error: {
+          message: reason['message'],
+          code: typeof reason['code'] === 'string' ? reason['code'] : 'UNKNOWN',
+        },
+      }
+      break
+    }
+    default:
+      return event
+  }
+  return { ...event, data: { ...data, reason: currentReason } } as SessionEvent
+}
+
+function canonicalizeLegacyMessageEvent(
+  event: SessionEvent,
+  id: SessionId,
+  messageIds: ReadonlyMap<number, PersistedMessageId>,
+): SessionEvent {
+  const data = asRecord(event.data)
+  if (data === undefined) return event
+  switch (event.type) {
+    case 'user/message':
+      if (Object.hasOwn(data, 'id') || Object.hasOwn(data, 'role')
+        || Object.hasOwn(data, 'message')
+        || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'source')) return event
+      return { ...event, data: { ...data, id: legacyMessageId(id, event.seq), role: 'user' } } as SessionEvent
+    case 'assistant/message': {
+      if (Object.hasOwn(data, 'message')
+        || !Object.hasOwn(data, 'content') || !Object.hasOwn(data, 'provenance')) return event
+      const { content, provenance, ...eventData } = data
+      return {
+        ...event,
+        data: {
+          ...eventData,
+          message: {
+            id: legacyMessageId(id, event.seq),
+            role: 'assistant',
+            content,
+            source: { ...asRecord(provenance), kind: 'model' },
+          },
+        },
+      } as SessionEvent
+    }
+    case 'tool/result': {
+      if (Object.hasOwn(data, 'message')
+        || !Object.hasOwn(data, 'callId') || !Object.hasOwn(data, 'content')
+        || !Object.hasOwn(data, 'isError')) return event
+      const { callId, content, isError, ...eventData } = data
+      const inheritedId = replacementStart(event)
+      return {
+        ...event,
+        data: {
+          ...eventData,
+          message: {
+            id: inheritedId === undefined ? legacyMessageId(id, event.seq) : messageIds.get(inheritedId),
+            role: 'user',
+            content: [{ type: 'tool-result', toolCallId: callId, content, isError }],
+            source: { kind: 'tool', callId },
+          },
+        },
+      } as SessionEvent
+    }
+    default:
+      return event
+  }
+}
+
+function eventMessageId(event: SessionEvent): PersistedMessageId | undefined {
+  const data = asRecord(event.data)
+  const message = event.type === 'user/message' ? data : asRecord(data?.['message'])
+  return typeof message?.['id'] === 'string' ? message['id'] as PersistedMessageId : undefined
+}
+
+async function* canonicalizeV0Events(
+  events: AsyncIterable<unknown>,
+  id: SessionId,
+): AsyncIterable<unknown> {
+  const messageIds = new Map<number, PersistedMessageId>()
+  for await (const value of events) {
+    const event = readV0Event(value, id)
+    const turnStart = canonicalizeLegacyTurnStartEvent(event, id)
+    const turnEnd = canonicalizeLegacyTurnEndEvent(turnStart, id)
+    const steering = canonicalizeLegacySteeringEvent(turnEnd, id)
+    const canonical = canonicalizeLegacyMessageEvent(steering, id, messageIds)
+    const messageId = eventMessageId(canonical)
+    if (messageId !== undefined) messageIds.set(canonical.seq, messageId)
+    yield canonical
+  }
+}
+
+/**
+ * Durable v0 includes first-party records whose structural changes were not
+ * accompanied by a format-version change. Their headers cannot select an
+ * adjacent-version migration, so this exact legacy recognition runs before
+ * any v0-to-v1 step and produces canonical v0 without changing the version.
+ * It remains necessary while v0 is current and whenever v0 is an upgrade
+ * source. Normalization alone is read-only; a selected versioned migration
+ * causes the canonicalized events to participate in atomic replacement.
+ */
+const V0_UNVERSIONED_FORMAT_COMPATIBILITY: UnversionedFormatCompatibility = Object.freeze({
+  version: 0,
+  requiresPrefix: requiresV0Prefix,
+  canonicalizeEvents: canonicalizeV0Events,
+})
+
+/**
+ * Select same-version compatibility for one stored header version.
+ * @param version - format version read from the stored header.
+ * @returns the static normalizer for that version, if one is required.
+ */
+export function unversionedFormatCompatibility(
+  version: number,
+): UnversionedFormatCompatibility | undefined {
+  return version === V0_UNVERSIONED_FORMAT_COMPATIBILITY.version
+    ? V0_UNVERSIONED_FORMAT_COMPATIBILITY
+    : undefined
+}

+ 15 - 6
packages/session/session-persistence/src/index.ts

@@ -12,7 +12,7 @@ import type { SessionPersistenceRevision } from './revision.ts'
 
 // Re-export the metadata vocabulary so Consumers import it from the Service Definition.
 export type { SessionHeader } from '@deepseek-ai/dsh-session'
-export { SessionPersistenceRevision } from './revision.ts'
+export { SessionPersistenceRevision, SessionPersistenceRevisionConflictError } from './revision.ts'
 
 /** Lightweight immutable source identity returned without loading a full log. */
 export interface SessionPersistenceSnapshot {
@@ -46,17 +46,17 @@ export {
   DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
   MAX_WRITE_BATCH_DELAY_MS,
   PersistenceCoordinator,
-  SessionFormatUnsupportedError,
   SessionPersistenceCorruptionError,
-  sessionFormatVersionRefusal,
 } from './coordinator.ts'
 export type {
   PersistenceBackend,
   PersistenceCoordinatorOptions,
-  StoredPrefix,
-  StoredSuffix,
 } from './coordinator.ts'
-
+export {
+  decodeStoredSessionHeader,
+  SessionFormatUnsupportedError,
+  sessionFormatVersionRefusal,
+} from './format-decoder.ts'
 declare module '@deepseek-ai/cordis' {
   interface Context {
     sessionPersistence: SessionPersistence
@@ -241,3 +241,12 @@ export abstract class SessionPersistence extends Service {
 }
 
 export default SessionPersistence
+
+export type {
+  SessionFormatContext,
+  SessionFormatStep,
+  StoredEventRead,
+  StoredEventReadCompletion,
+  StoredEventReadOptions,
+  StoredSessionSource,
+} from './format-decoder.ts'

+ 9 - 0
packages/session/session-persistence/src/revision.ts

@@ -16,3 +16,12 @@ export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'>
 export function SessionPersistenceRevision(value: string): SessionPersistenceRevision {
   return value as SessionPersistenceRevision
 }
+
+/** A repeatable source can no longer reproduce the revision it represents. */
+export class SessionPersistenceRevisionConflictError extends Error {
+  /** @param message - source identity and expected revision context. */
+  constructor(message: string) {
+    super(message)
+    this.name = 'SessionPersistenceRevisionConflictError'
+  }
+}

+ 889 - 0
packages/session/session-persistence/tests/format-decoder.spec.ts

@@ -0,0 +1,889 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+  SessionPersistenceRevision,
+  SessionPersistenceRevisionConflictError,
+} from '../src/revision.ts'
+import type {
+  SessionFormatContext,
+  SessionFormatStep,
+  StoredEventReadCompletion,
+  StoredSessionSource,
+} from '../src/format-decoder.ts'
+import { sessionFormatVersionRefusal } from '../src/format-decoder.ts'
+import { unversionedFormatCompatibility } from '../src/format-v0-compat.ts'
+
+const id = SessionId('format-migration')
+
+function eventLog(): SessionEvent[] {
+  return [
+    { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+    { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
+  ]
+}
+
+async function collectEvents(events: AsyncIterable<SessionEvent>): Promise<SessionEvent[]> {
+  const collected: SessionEvent[] = []
+  for await (const event of events) collected.push(event)
+  return collected
+}
+
+async function decodedFailure(
+  decoded: ReturnType<typeof import('../src/format-decoder.ts')['decodeStoredSession']>,
+): Promise<Error> {
+  const completion = decoded.completed.catch((error: unknown) => error)
+  const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
+  const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
+  expect(streamFailure).toBe(completionFailure)
+  expect(streamFailure).toBeInstanceOf(Error)
+  return streamFailure as Error
+}
+
+function storedSource(
+  version: number,
+  events: readonly unknown[],
+): { source: StoredSessionSource<never>; reads: number[]; meta: Record<string, unknown> } {
+  const reads: number[] = []
+  const meta: Record<string, unknown> = { version, id, createdAt: 1 }
+  return {
+    meta,
+    reads,
+    source: {
+      meta,
+      revision: SessionPersistenceRevision(`format-v${version}`),
+      readEvents({ fromSeq = 0 } = {}) {
+        reads.push(fromSeq)
+        return {
+          events: (async function* (): AsyncIterable<unknown> {
+            for (const event of events) {
+              const seq = typeof event === 'object' && event !== null
+                ? (event as { seq?: unknown }).seq
+                : undefined
+              if (!Number.isSafeInteger(seq) || (seq as number) < 0 || (seq as number) >= fromSeq) {
+                yield structuredClone(event)
+              }
+            }
+          })(),
+          completed: Promise.resolve({}),
+        }
+      },
+    },
+  }
+}
+
+function migration(
+  from: number,
+  calls: string[],
+): SessionFormatStep {
+  return {
+    from,
+    to: from + 1,
+    migrateHeader(meta) {
+      calls.push(`header:${from}`)
+      return { ...(meta as Record<string, unknown>), version: from + 1 }
+    },
+    migrateEvents(events) {
+      return (async function* (): AsyncIterable<unknown> {
+        let observedInput = false
+        for await (const value of events) {
+          if (!observedInput) {
+            calls.push(`events:${from}`)
+            observedInput = true
+          }
+          const event = value as SessionEvent
+          const data = event.data as Record<string, unknown>
+          const migrationPath = Array.isArray(data['migrationPath'])
+            ? data['migrationPath'] as unknown[]
+            : []
+          yield {
+            ...event,
+            data: {
+              ...data,
+              [`migratedFrom${from}`]: true,
+              migrationPath: [...migrationPath, from],
+            },
+          }
+        }
+      })()
+    },
+  }
+}
+
+async function configuredDecoder(
+  currentVersion: number,
+  migrations: readonly SessionFormatStep[],
+  calls: string[] = [],
+): Promise<{
+  decodeStoredSession: typeof import('../src/format-decoder.ts')['decodeStoredSession']
+  decodeStoredSessionHeader: typeof import('../src/format-decoder.ts')['decodeStoredSessionHeader']
+  validateHeader: ReturnType<typeof vi.fn>
+}> {
+  vi.resetModules()
+  const validateHeader = vi.fn((sessionId: SessionId, _seed: unknown, meta: unknown) => {
+    calls.push('validate-header')
+    const record = meta as Record<string, unknown>
+    if (record['version'] !== currentVersion) {
+      throw new Error(`current header validator received v${String(record['version'])}`)
+    }
+    if (record['id'] !== sessionId) throw new Error('current header validator received the wrong id')
+    if (!Number.isSafeInteger(record['createdAt'])) {
+      throw new Error('current header validator received invalid createdAt')
+    }
+    return { header: Object.freeze(structuredClone(record)) }
+  })
+  vi.doMock('@deepseek-ai/dsh-session', async () => {
+    const actual = await vi.importActual<typeof import('@deepseek-ai/dsh-session')>(
+      '@deepseek-ai/dsh-session',
+    )
+    return {
+      ...actual,
+      SESSION_FORMAT_VERSION: currentVersion,
+      Session: { create: validateHeader },
+    }
+  })
+  vi.doMock('../src/format-migrations/index.ts', () => ({
+    SESSION_FORMAT_STEPS: migrations,
+  }))
+  const decoder = await import('../src/format-decoder.ts')
+  return {
+    decodeStoredSession: decoder.decodeStoredSession,
+    decodeStoredSessionHeader: decoder.decodeStoredSessionHeader,
+    validateHeader,
+  }
+}
+
+afterEach(() => {
+  vi.doUnmock('@deepseek-ai/dsh-session')
+  vi.doUnmock('../src/format-migrations/index.ts')
+  vi.resetModules()
+})
+
+describe('versioned Session format decoder', { concurrent: false }, () => {
+  it('describes both unsupported format directions', () => {
+    expect(sessionFormatVersionRefusal(id, 1)).toContain('newer harness')
+    expect(sessionFormatVersionRefusal(id, -1)).toContain('older than the supported')
+  })
+
+  it('runs a single migration lazily and reads the complete old log before slicing', async () => {
+    const calls: string[] = []
+    const step = migration(0, calls)
+    const { decodeStoredSession, validateHeader } = await configuredDecoder(1, [step], calls)
+    const originalEvents = eventLog()
+    const originalSnapshot = structuredClone(originalEvents)
+    const stored = storedSource(0, originalEvents)
+
+    const decoded = decodeStoredSession(stored.source, id, 1)
+    expect(decoded.sourceVersion).toBe(0)
+    expect(decoded.meta.version).toBe(1)
+    expect(calls).toEqual(['header:0', 'validate-header'])
+    expect(stored.reads).toEqual([])
+
+    const migrated = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(stored.reads).toEqual([0])
+    expect(calls).toEqual(['header:0', 'validate-header', 'events:0'])
+    expect(migrated).toEqual([
+      {
+        ...originalEvents[1],
+        data: { ...originalEvents[1]?.data, migratedFrom0: true, migrationPath: [0] },
+      },
+    ])
+    expect(originalEvents).toEqual(originalSnapshot)
+    expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 })
+    expect(validateHeader).toHaveBeenCalledOnce()
+  })
+
+  it('lets an old-format suffix migration use facts from events before fromSeq', async () => {
+    const step: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
+      migrateEvents: events => (async function* (): AsyncIterable<unknown> {
+        let previousSeq: number | undefined
+        for await (const value of events) {
+          const event = value as SessionEvent
+          yield previousSeq === undefined
+            ? event
+            : { ...event, data: { ...event.data, previousSeq } }
+          previousSeq = event.seq
+        }
+      })(),
+    }
+    const { decodeStoredSession } = await configuredDecoder(1, [step])
+    const stored = storedSource(0, eventLog())
+
+    const decoded = decodeStoredSession(stored.source, id, 1)
+    const events = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(stored.reads).toEqual([0])
+    expect(events).toEqual([{
+      ...eventLog()[1],
+      data: { ...eventLog()[1]?.data, previousSeq: 0 },
+    }])
+  })
+
+  it('streams migrated events with backpressure instead of buffering the complete log', async () => {
+    const releaseTail = Promise.withResolvers<undefined>()
+    const physicalCompletion = Promise.withResolvers<StoredEventReadCompletion<never>>()
+    const reads: number[] = []
+    const source: StoredSessionSource<never> = {
+      meta: { version: 0, id, createdAt: 1 },
+      revision: SessionPersistenceRevision('streaming-source'),
+      readEvents({ fromSeq = 0 } = {}) {
+        reads.push(fromSeq)
+        return {
+          events: (async function* (): AsyncIterable<unknown> {
+            try {
+              yield structuredClone(eventLog()[0])
+              await releaseTail.promise
+              yield structuredClone(eventLog()[1])
+              physicalCompletion.resolve({})
+            } catch (error: unknown) {
+              physicalCompletion.reject(error)
+              throw error
+            }
+          })(),
+          completed: physicalCompletion.promise,
+        }
+      },
+    }
+    const { decodeStoredSession } = await configuredDecoder(1, [migration(0, [])])
+    const decoded = decodeStoredSession(source, id)
+    const iterator = decoded.events[Symbol.asyncIterator]()
+
+    const first = await iterator.next()
+    expect(first).toMatchObject({ done: false, value: { seq: 0 } })
+    expect(reads).toEqual([0])
+    let completed = false
+    void decoded.completed.then(() => { completed = true })
+    await Promise.resolve()
+    expect(completed).toBe(false)
+
+    releaseTail.resolve(undefined)
+    await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { seq: 1 } })
+    await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
+    await expect(decoded.completed).resolves.toEqual({})
+  })
+
+  it('runs a complete multi-step chain before current header and event validation', async () => {
+    const calls: string[] = []
+    const { decodeStoredSession } = await configuredDecoder(
+      2,
+      [migration(0, calls), migration(1, calls)],
+      calls,
+    )
+    const stored = storedSource(0, eventLog())
+
+    const decoded = decodeStoredSession(stored.source, id)
+    expect(decoded.meta.version).toBe(2)
+    expect(calls).toEqual(['header:0', 'header:1', 'validate-header'])
+
+    const events = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(calls).toEqual([
+      'header:0',
+      'header:1',
+      'validate-header',
+      'events:0',
+      'events:1',
+    ])
+    expect(events[0]?.data).toMatchObject({ migratedFrom0: true, migratedFrom1: true })
+    expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] })
+  })
+
+  it('plans by version even when registry entries are declared out of order', async () => {
+    const calls: string[] = []
+    const { decodeStoredSession } = await configuredDecoder(
+      2,
+      [migration(1, calls), migration(0, calls)],
+      calls,
+    )
+
+    const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
+    const events = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(calls.slice(0, 3)).toEqual(['header:0', 'header:1', 'validate-header'])
+    expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] })
+  })
+
+  it('starts a multi-version registry at the source version', async () => {
+    const calls: string[] = []
+    const { decodeStoredSession } = await configuredDecoder(
+      2,
+      [migration(0, calls), migration(1, calls)],
+      calls,
+    )
+    const stored = storedSource(1, eventLog())
+
+    const decoded = decodeStoredSession(stored.source, id, 1)
+    const events = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(calls).toEqual(['header:1', 'validate-header', 'events:1'])
+    expect(stored.reads).toEqual([0])
+    expect(events[0]?.data).toMatchObject({ migrationPath: [1] })
+  })
+
+  it('passes the stable stored identity to every header and event step', async () => {
+    const contexts: SessionFormatContext[] = []
+    const step: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader(meta, context) {
+        contexts.push(context)
+        return { ...(meta as Record<string, unknown>), version: 1 }
+      },
+      migrateEvents(events, context) {
+        contexts.push(context)
+        return events
+      },
+    }
+    const { decodeStoredSession } = await configuredDecoder(1, [step])
+
+    const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
+    await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(contexts).toEqual([{ sessionId: id }, { sessionId: id }])
+  })
+
+  it('migrates and validates a header without requiring an event source', async () => {
+    const calls: string[] = []
+    const { decodeStoredSessionHeader } = await configuredDecoder(
+      2,
+      [migration(0, calls), migration(1, calls)],
+      calls,
+    )
+
+    const header = decodeStoredSessionHeader({ version: 0, id, createdAt: 1 }, id)
+
+    expect(header.version).toBe(2)
+    expect(calls).toEqual(['header:0', 'header:1', 'validate-header'])
+  })
+
+  it('applies event migration before the current event vocabulary check', async () => {
+    const step: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
+      migrateEvents: events => (async function* (): AsyncIterable<unknown> {
+        for await (const value of events) {
+          const event = value as Record<string, unknown>
+          yield { ...event, type: 'turn/start', data: { turn: 1 } }
+        }
+      })(),
+    }
+    const { decodeStoredSession } = await configuredDecoder(1, [step])
+    const stored = storedSource(0, [
+      { type: 'legacy/turn-begin', seq: 0, time: 1, data: { legacyTurn: 1 } },
+    ])
+
+    const decoded = decodeStoredSession(stored.source, id)
+    const events = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(events).toEqual([
+      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+    ])
+  })
+
+  it('uses suffix access directly for the current format', async () => {
+    const { decodeStoredSession } = await configuredDecoder(2, [])
+    const stored = storedSource(2, eventLog())
+
+    const decoded = decodeStoredSession(stored.source, id, 1)
+    const events = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(stored.reads).toEqual([1])
+    expect(events).toEqual(eventLog().slice(1))
+  })
+
+  it('buffers a safe current-v0 suffix once without reopening the prefix', async () => {
+    const { decodeStoredSession } = await configuredDecoder(0, [])
+    const stored = storedSource(0, eventLog())
+
+    const decoded = decodeStoredSession(stored.source, id, 1)
+    expect(await collectEvents(decoded.events)).toEqual(eventLog().slice(1))
+    await expect(decoded.completed).resolves.toEqual({})
+
+    expect(stored.reads).toEqual([1])
+  })
+
+  it('reopens the complete current-v0 log when a legacy suffix record needs its prefix', async () => {
+    const { decodeStoredSession } = await configuredDecoder(0, [])
+    const legacy = [
+      { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+      {
+        type: 'steering/message',
+        seq: 1,
+        time: 2,
+        data: { turn: 1, content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } },
+      },
+    ]
+    const stored = storedSource(0, legacy)
+
+    const decoded = decodeStoredSession(stored.source, id, 1)
+    const events = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(stored.reads).toEqual([1, 0])
+    expect(events).toMatchObject([{ type: 'user/message', seq: 1 }])
+  })
+
+  it('observes a failed physical completion after reopening a required v0 prefix', async () => {
+    const failure = new SessionPersistenceRevisionConflictError('reopened prefix changed')
+    const fullCompletion = Promise.withResolvers<StoredEventReadCompletion<never>>()
+    const source: StoredSessionSource<never> = {
+      meta: { version: 0, id, createdAt: 1 },
+      revision: SessionPersistenceRevision('prefix-conflict'),
+      readEvents({ fromSeq = 0 } = {}) {
+        if (fromSeq > 0) {
+          return {
+            events: (async function* (): AsyncIterable<unknown> {
+              yield {
+                type: 'steering/message', seq: 1, time: 2,
+                data: { turn: 1, content: [], source: { kind: 'user' } },
+              }
+            })(),
+            completed: Promise.resolve({}),
+          }
+        }
+        return {
+          events: (async function* (): AsyncIterable<unknown> {
+            fullCompletion.reject(failure)
+            throw failure
+          })(),
+          completed: fullCompletion.promise,
+        }
+      },
+    }
+    const { decodeStoredSession } = await configuredDecoder(0, [])
+    const decoded = decodeStoredSession(source, id, 1)
+
+    await expect(decodedFailure(decoded)).resolves.toBe(failure)
+  })
+
+  it('classifies every v0 prefix-independent suffix value without assuming a record', () => {
+    const compatibility = unversionedFormatCompatibility(0)
+    if (compatibility === undefined) throw new Error('v0 compatibility must be registered')
+
+    expect(compatibility.requiresPrefix(null)).toBe(false)
+    expect(compatibility.requiresPrefix({ type: 'turn/end', data: null })).toBe(false)
+    expect(compatibility.requiresPrefix({ type: 'user/message', data: { id: 'current', content: [] } })).toBe(false)
+    expect(compatibility.requiresPrefix({ type: 'user/message', data: { content: [] } })).toBe(true)
+    expect(compatibility.requiresPrefix({ type: 'assistant/message', data: { content: [] } })).toBe(true)
+    expect(compatibility.requiresPrefix({ type: 'tool/result', data: { callId: 'call' } })).toBe(true)
+  })
+
+  it('preserves already-canonical v0 turn-end reasons', async () => {
+    const compatibility = unversionedFormatCompatibility(0)
+    if (compatibility === undefined) throw new Error('v0 compatibility must be registered')
+    const events = [
+      {
+        type: 'turn/end', seq: 0, time: 1,
+        data: { turn: 1, reason: { kind: 'aborted', reason: { kind: 'disposed' } } },
+      },
+      {
+        type: 'turn/end', seq: 1, time: 2,
+        data: { turn: 2, reason: { kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } } },
+      },
+    ]
+    const input = (async function* (): AsyncIterable<unknown> {
+      yield* events
+    })()
+    const canonical: unknown[] = []
+
+    for await (const event of compatibility.canonicalizeEvents(input, id)) canonical.push(event)
+
+    expect(canonical).toEqual(events)
+  })
+
+  it('does not run older registered steps for an already-current source', async () => {
+    const calls: string[] = []
+    const { decodeStoredSession } = await configuredDecoder(
+      2,
+      [migration(0, calls), migration(1, calls)],
+      calls,
+    )
+    const stored = storedSource(2, eventLog())
+
+    const decoded = decodeStoredSession(stored.source, id, 1)
+    await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(calls).toEqual(['validate-header'])
+    expect(stored.reads).toEqual([1])
+  })
+
+  it('opens a fresh revision-bound reader for each decode of the same source', async () => {
+    const { decodeStoredSession } = await configuredDecoder(0, [])
+    const stored = storedSource(0, eventLog())
+
+    const first = decodeStoredSession(stored.source, id)
+    expect(await collectEvents(first.events)).toEqual(eventLog())
+    await first.completed
+    const second = decodeStoredSession(stored.source, id)
+    expect(await collectEvents(second.events)).toEqual(eventLog())
+    await second.completed
+
+    expect(first.revision).toBe(second.revision)
+    expect(stored.reads).toEqual([0, 0])
+  })
+
+  it('propagates a physical revision conflict unchanged through events and completion', async () => {
+    const failure = new SessionPersistenceRevisionConflictError('source changed')
+    const physicalCompletion = Promise.withResolvers<StoredEventReadCompletion<never>>()
+    const source: StoredSessionSource<never> = {
+      meta: { version: 0, id, createdAt: 1 },
+      revision: SessionPersistenceRevision('conflicting-source'),
+      readEvents: () => ({
+        events: (async function* (): AsyncIterable<unknown> {
+          physicalCompletion.reject(failure)
+          throw failure
+        })(),
+        completed: physicalCompletion.promise,
+      }),
+    }
+    const { decodeStoredSession } = await configuredDecoder(0, [])
+    const decoded = decodeStoredSession(source, id)
+    const completion = decoded.completed.catch((error: unknown) => error)
+    const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
+
+    const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
+    expect(streamFailure).toBe(failure)
+    expect(completionFailure).toBe(failure)
+  })
+
+  it('rejects a missing path and a future source in the correct direction', async () => {
+    const { decodeStoredSession, validateHeader } = await configuredDecoder(2, [])
+    const old = storedSource(0, [])
+    const future = storedSource(3, [])
+
+    expect(() => decodeStoredSession(old.source, id))
+      .toThrow(/missing v0 -> v1/)
+    expect(() => decodeStoredSession(future.source, id))
+      .toThrow(/newer harness/)
+    expect(old.reads).toEqual([])
+    expect(future.reads).toEqual([])
+    expect(validateHeader).not.toHaveBeenCalled()
+  })
+
+  it('preserves the raw location in unsupported-format diagnostics', async () => {
+    const { decodeStoredSession } = await configuredDecoder(0, [])
+    const stored = storedSource(1, [])
+    const location = { kind: 'jsonl', path: '/tmp/session.jsonl' }
+    const source: StoredSessionSource<never> = { ...stored.source, location }
+
+    let failure: unknown
+    try {
+      decodeStoredSession(source, id)
+    } catch (error: unknown) {
+      failure = error
+    }
+    expect(failure).toMatchObject({
+      name: 'SessionFormatUnsupportedError',
+      location,
+    })
+    expect((failure as Error).message).toContain('(raw log: /tmp/session.jsonl)')
+  })
+
+  it('rejects an invalid suffix before validating the header or opening events', async () => {
+    const { decodeStoredSession, validateHeader } = await configuredDecoder(0, [])
+    const stored = storedSource(0, eventLog())
+
+    for (const fromSeq of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
+      expect(() => decodeStoredSession(stored.source, id, fromSeq))
+        .toThrow(/fromSeq must be a non-negative safe integer/)
+    }
+    expect(validateHeader).not.toHaveBeenCalled()
+    expect(stored.reads).toEqual([])
+  })
+
+  it('validates unknown durable header fields before path selection', async () => {
+    const { decodeStoredSession, validateHeader } = await configuredDecoder(0, [])
+    const cases: Array<{ meta: unknown; message: RegExp }> = [
+      { meta: null, message: /header is not a lossless JSON record/ },
+      { meta: { version: '0', id }, message: /invalid format version/ },
+      { meta: { version: 0, id: 42 }, message: /has no string id/ },
+    ]
+    let reads = 0
+
+    for (const entry of cases) {
+      const source: StoredSessionSource<never> = {
+        meta: entry.meta,
+        revision: SessionPersistenceRevision('invalid-header'),
+        readEvents: () => {
+          reads += 1
+          return { events: (async function* () {})(), completed: Promise.resolve({}) }
+        },
+      }
+      expect(() => decodeStoredSession(source, id)).toThrow(entry.message)
+    }
+    expect(reads).toBe(0)
+    expect(validateHeader).not.toHaveBeenCalled()
+  })
+
+  it('rejects every malformed current event envelope through the stream and completion', async () => {
+    const { decodeStoredSession } = await configuredDecoder(1, [])
+    const cases: Array<{ value: unknown; message: RegExp }> = [
+      { value: null, message: /non-record event/ },
+      { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ },
+      { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ },
+      { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ },
+      { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ },
+    ]
+
+    for (const entry of cases) {
+      const decoded = decodeStoredSession(storedSource(1, [entry.value]).source, id)
+      expect((await decodedFailure(decoded)).message).toMatch(entry.message)
+    }
+  })
+
+  it('rejects a stored event that cannot be represented as JSON', async () => {
+    const { decodeStoredSession } = await configuredDecoder(1, [])
+    const decoded = decodeStoredSession(storedSource(1, [undefined]).source, id)
+
+    expect((await decodedFailure(decoded)).message).toMatch(/not losslessly JSON-serializable/)
+  })
+
+  it('rejects every malformed v0 event before same-version canonicalization', async () => {
+    const { decodeStoredSession } = await configuredDecoder(0, [])
+    const cases: Array<{ value: unknown; message: RegExp }> = [
+      { value: null, message: /non-record event/ },
+      { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ },
+      { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ },
+      { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ },
+      { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ },
+    ]
+
+    for (const entry of cases) {
+      const decoded = decodeStoredSession(storedSource(0, [entry.value]).source, id)
+      expect((await decodedFailure(decoded)).message).toMatch(entry.message)
+    }
+  })
+
+  it('lets a v0 turn/end with opaque data reach current validation unchanged', async () => {
+    const { decodeStoredSession } = await configuredDecoder(0, [])
+    const decoded = decodeStoredSession(storedSource(0, [
+      { type: 'turn/end', seq: 0, time: 1, data: null },
+    ]).source, id)
+
+    await expect(collectEvents(decoded.events)).resolves.toEqual([
+      { type: 'turn/end', seq: 0, time: 1, data: null },
+    ])
+    await expect(decoded.completed).resolves.toEqual({})
+  })
+
+  it('rejects a step that returns the wrong header version', async () => {
+    const bad: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 0 }),
+      migrateEvents: events => events,
+    }
+    const first = await configuredDecoder(1, [bad])
+
+    expect(() => first.decodeStoredSession(storedSource(0, []).source, id))
+      .toThrow(/returned header version 0/)
+    expect(first.validateHeader).not.toHaveBeenCalled()
+
+    const calls: string[] = []
+    const badSecond: SessionFormatStep = {
+      from: 1,
+      to: 2,
+      migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
+      migrateEvents: events => events,
+    }
+    const second = await configuredDecoder(2, [migration(0, calls), badSecond], calls)
+    const stored = storedSource(0, [])
+    expect(() => second.decodeStoredSession(stored.source, id))
+      .toThrow(/v1 -> v2 returned header version 1/)
+    expect(calls).toEqual(['header:0'])
+    expect(second.validateHeader).not.toHaveBeenCalled()
+    expect(stored.reads).toEqual([])
+  })
+
+  it('rejects a step that changes the session id or cwd storage identity', async () => {
+    const changedId: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1, id: 'other' }),
+      migrateEvents: events => events,
+    }
+    const first = await configuredDecoder(1, [changedId])
+    expect(() => first.decodeStoredSession(storedSource(0, []).source, id))
+      .toThrow(/changed session storage identity/)
+
+    const changedCwd: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1, cwd: '/other' }),
+      migrateEvents: events => events,
+    }
+    const second = await configuredDecoder(1, [changedCwd])
+    const stored = storedSource(0, [])
+    stored.meta['cwd'] = '/work'
+    expect(() => second.decodeStoredSession(stored.source, id))
+      .toThrow(/changed session storage identity/)
+  })
+
+  it('wraps a header migration failure with the failing version step', async () => {
+    const cause = new Error('bad legacy header')
+    const step: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader: () => { throw cause },
+      migrateEvents: events => events,
+    }
+    const { decodeStoredSession } = await configuredDecoder(1, [step])
+
+    let failure: unknown
+    try {
+      decodeStoredSession(storedSource(0, []).source, id)
+    } catch (error: unknown) {
+      failure = error
+    }
+    expect(failure).toMatchObject({
+      message: `session "${id}" header migration v0 -> v1 failed`,
+      cause,
+    })
+  })
+
+  it('mirrors an event migration failure through the stream and completion promise', async () => {
+    const cause = new Error('bad legacy event')
+    const step: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
+      migrateEvents: () => (async function* (): AsyncIterable<unknown> {
+        throw cause
+      })(),
+    }
+    const { decodeStoredSession } = await configuredDecoder(1, [step])
+    const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
+    const completion = decoded.completed.catch((error: unknown) => error)
+    const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
+
+    const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
+    expect(streamFailure).toBe(completionFailure)
+    expect(streamFailure).toMatchObject({
+      message: `session "${id}" event migration v0 -> v1 failed`,
+      cause,
+    })
+  })
+
+  it('runs current event validation on the migrated output', async () => {
+    const step: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
+      migrateEvents: events => (async function* (): AsyncIterable<unknown> {
+        for await (const value of events) {
+          const event = value as SessionEvent
+          yield { ...event, seq: event.seq + 1 }
+        }
+      })(),
+    }
+    const { decodeStoredSession } = await configuredDecoder(1, [step])
+    const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
+    const completion = decoded.completed.catch((error: unknown) => error)
+    const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
+
+    const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
+    expect(streamFailure).toBe(completionFailure)
+    expect((streamFailure as Error).message).toMatch(/expected 0, got 1/)
+  })
+
+  it('runs current header validation only after the final header step', async () => {
+    const calls: string[] = []
+    const finalStep: SessionFormatStep = {
+      from: 1,
+      to: 2,
+      migrateHeader(meta) {
+        calls.push('header:1')
+        const { createdAt: _createdAt, ...rest } = meta as Record<string, unknown>
+        return { ...rest, version: 2 }
+      },
+      migrateEvents: events => events,
+    }
+    const { decodeStoredSession } = await configuredDecoder(
+      2,
+      [migration(0, calls), finalStep],
+      calls,
+    )
+    const stored = storedSource(0, eventLog())
+
+    expect(() => decodeStoredSession(stored.source, id))
+      .toThrow(/current header validator received invalid createdAt/)
+    expect(calls).toEqual(['header:0', 'header:1', 'validate-header'])
+    expect(stored.reads).toEqual([])
+  })
+
+  it('detaches stored header and event objects before a mutating migration runs', async () => {
+    const originalEvents = eventLog()
+    const eventSnapshot = structuredClone(originalEvents)
+    const step: SessionFormatStep = {
+      from: 0,
+      to: 1,
+      migrateHeader(meta) {
+        const record = meta as Record<string, unknown>
+        record['version'] = 1
+        return record
+      },
+      migrateEvents: events => (async function* (): AsyncIterable<unknown> {
+        for await (const value of events) {
+          const event = value as SessionEvent
+          const data = event.data as Record<string, unknown>
+          data['mutated'] = true
+          yield event
+        }
+      })(),
+    }
+    const { decodeStoredSession } = await configuredDecoder(1, [step])
+    const stored = storedSource(0, originalEvents)
+
+    const decoded = decodeStoredSession(stored.source, id)
+    const migrated = await collectEvents(decoded.events)
+    await decoded.completed
+
+    expect(migrated.every(event => (event.data as Record<string, unknown>)['mutated'] === true)).toBe(true)
+    expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 })
+    expect(originalEvents).toEqual(eventSnapshot)
+  })
+
+  it('rejects duplicate, invalid, future-targeting, and incomplete static registries at initialization', async () => {
+    const calls: string[] = []
+    await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)]))
+      .rejects.toThrow(/duplicate Session format step/)
+
+    await expect(configuredDecoder(1, [migration(-1, calls)]))
+      .rejects.toThrow(/adjacent non-negative version/)
+
+    const nonAdjacent: SessionFormatStep = {
+      ...migration(0, calls),
+      to: 2,
+    }
+    await expect(configuredDecoder(2, [nonAdjacent]))
+      .rejects.toThrow(/adjacent non-negative version/)
+
+    const fractional: SessionFormatStep = {
+      ...migration(0, calls),
+      from: 0.5,
+      to: 1.5,
+    }
+    await expect(configuredDecoder(2, [fractional]))
+      .rejects.toThrow(/adjacent non-negative version/)
+
+    await expect(configuredDecoder(1, [migration(1, calls)]))
+      .rejects.toThrow(/targets a version newer than this build/)
+
+    await expect(configuredDecoder(2, [migration(0, calls)]))
+      .rejects.toThrow(/incomplete path/)
+  })
+})

+ 347 - 22
packages/session/session-persistence/tests/persistence.spec.ts

@@ -5,10 +5,13 @@ import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
 import {
   DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
   SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
-  type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, type StoredSuffix,
+  SessionPersistenceRevisionConflictError,
+  type PersistenceBackend, type SessionPersistenceSnapshot, type StoredEventRead,
+  type StoredEventReadCompletion, type StoredSessionSource,
 } from '../src/index.ts'
 import { runPersistenceContract, meta, oneTurnLog } from './contract.ts'
 import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts'
+import * as formatDecoder from '../src/format-decoder.ts'
 
 /** The durable store shape: materialized sessions only (no lazy entries). */
 type MemoryStore = Map<string, { meta: SessionHeader; events: SessionEvent[] }>
@@ -18,6 +21,45 @@ function memoryRevision(entry: { meta: SessionHeader; events: SessionEvent[] }):
   return SessionPersistenceRevision(JSON.stringify(entry))
 }
 
+/** Build one lazy physical read whose completion follows iterator exhaustion. */
+function storedRead<TornMarker>(
+  load: () => Promise<{ events: readonly unknown[]; tornMarker?: TornMarker }>,
+): StoredEventRead<TornMarker> {
+  const completed = Promise.withResolvers<StoredEventReadCompletion<TornMarker>>()
+  const events = (async function* (): AsyncIterable<unknown> {
+    try {
+      const loaded = await load()
+      yield* loaded.events
+      completed.resolve(loaded.tornMarker === undefined ? {} : { tornMarker: loaded.tornMarker })
+    } catch (error: unknown) {
+      completed.reject(error)
+      throw error
+    }
+  })()
+  return { events, completed: completed.promise }
+}
+
+/** Materialize an async replacement stream for the map-backed test stores. */
+async function collectReplacement(events: AsyncIterable<SessionEvent>): Promise<SessionEvent[]> {
+  const collected: SessionEvent[] = []
+  for await (const event of events) collected.push(structuredClone(event))
+  return collected
+}
+
+async function replaceMemoryStored(
+  store: MemoryStore,
+  expectedRevision: SessionPersistenceRevision,
+  m: SessionHeader,
+  events: AsyncIterable<SessionEvent>,
+): Promise<void> {
+  const entry = store.get(m.id)
+  if (entry === undefined || memoryRevision(entry) !== expectedRevision) {
+    throw new SessionPersistenceRevisionConflictError(`session "${m.id}" changed before replacement`)
+  }
+  if (entry.meta.cwd !== m.cwd) throw new Error(`replacement for session "${m.id}" changes its stored identity`)
+  store.set(m.id, { meta: structuredClone(m), events: await collectReplacement(events) })
+}
+
 /** An obsolete event fixture that emulates an untyped pre-change producer. */
 function legacyHeaderDelta(seq = 0): SessionEvent {
   return {
@@ -82,7 +124,7 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
     super(ctx)
     // Assign the store BEFORE constructing the coordinator: the coordinator's
     // constructor installs the write path and synchronously seeds existing live
-    // sessions through loadStored(), so store must exist first.
+    // sessions through openStored(), so store must exist first.
     this.store = config?.store ?? new Map<string, { meta: SessionHeader; events: SessionEvent[] }>()
     this.coordinator = new PersistenceCoordinator<never>(this.ctx, this)
   }
@@ -121,13 +163,20 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
   // --- PersistenceBackend hooks (the Map storage primitives) ---
 
   // A Map-backed store has no torn tails, so `tornMarker` is never set.
-  async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> {
+  async openStored(id: SessionId): Promise<StoredSessionSource<never> | undefined> {
     const entry = this.store.get(id)
     if (!entry) return undefined
+    const revision = memoryRevision(entry)
     return {
       meta: structuredClone(entry.meta),
-      events: structuredClone(entry.events),
-      revision: memoryRevision(entry),
+      revision,
+      readEvents: ({ fromSeq = 0 } = {}) => storedRead(async () => {
+        const current = this.store.get(id)
+        if (current === undefined || memoryRevision(current) !== revision) {
+          throw new SessionPersistenceRevisionConflictError(`session "${id}" changed during read`)
+        }
+        return { events: structuredClone(current.events.filter(event => event.seq >= fromSeq)) }
+      }),
     }
   }
 
@@ -161,6 +210,14 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
     if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[])
   }
 
+  async replaceStored(
+    expectedRevision: SessionPersistenceRevision,
+    m: SessionHeader,
+    events: AsyncIterable<SessionEvent>,
+  ): Promise<void> {
+    await replaceMemoryStored(this.store, expectedRevision, m, events)
+  }
+
   async list(signal?: AbortSignal): Promise<SessionHeader[]> {
     signal?.throwIfAborted()
     return [...this.store.values()].map(e => structuredClone(e.meta))
@@ -185,23 +242,36 @@ class ControlledBackend implements PersistenceBackend<never> {
   repairAttempts = 0
   beforeAppend?: (attempt: number) => Promise<void>
   beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void>
-  /** When set, the declared seek hook delegates here so readFrom exercises it; unset throws (tests set it first). */
-  seekHook?: (id: SessionId, fromSeq: number, signal?: AbortSignal) => Promise<StoredSuffix | undefined>
-
-  loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<StoredSuffix | undefined> {
-    if (this.seekHook === undefined) throw new Error('seekHook not configured for this test')
-    return this.seekHook(id, fromSeq, signal)
-  }
-
-  async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> {
+  /** Optional physical suffix hook used by readFrom-specific tests. */
+  seekHook?: (
+    id: SessionId,
+    fromSeq: number,
+    signal?: AbortSignal,
+  ) => Promise<{ meta: SessionHeader; events: SessionEvent[] } | undefined>
+
+  async openStored(id: SessionId, signal?: AbortSignal): Promise<StoredSessionSource<never> | undefined> {
     const attempt = ++this.loadAttempts
     await this.beforeLoadStored?.(attempt, signal)
     const entry = this.store.get(id)
     if (entry === undefined) return undefined
+    const revision = memoryRevision(entry)
     return {
       meta: structuredClone(entry.meta),
-      events: structuredClone(entry.events),
-      revision: memoryRevision(entry),
+      revision,
+      readEvents: ({ fromSeq = 0 } = {}) => storedRead(async () => {
+        signal?.throwIfAborted()
+        const loaded = this.seekHook === undefined
+          ? { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) }
+          : await this.seekHook(id, fromSeq, signal)
+        if (loaded === undefined) {
+          throw new SessionPersistenceRevisionConflictError(`session "${id}" disappeared during read`)
+        }
+        const current = this.store.get(id)
+        if (current === undefined || memoryRevision(current) !== revision) {
+          throw new SessionPersistenceRevisionConflictError(`session "${id}" changed during read`)
+        }
+        return { events: structuredClone(loaded.events) }
+      }),
     }
   }
 
@@ -228,6 +298,14 @@ class ControlledBackend implements PersistenceBackend<never> {
     if (entry !== undefined) entry.events.push(...structuredClone(closers) as SessionEvent[])
   }
 
+  async replaceStored(
+    expectedRevision: SessionPersistenceRevision,
+    m: SessionHeader,
+    events: AsyncIterable<SessionEvent>,
+  ): Promise<void> {
+    await replaceMemoryStored(this.store, expectedRevision, m, events)
+  }
+
   async list(): Promise<SessionHeader[]> {
     return [...this.store.values()].map(entry => structuredClone(entry.meta))
   }
@@ -537,6 +615,11 @@ describe('PersistenceCoordinator session preparations', () => {
     }, { inject: ['sessions'] }))
 
     try {
+      const immediatelyLive = Session.create(prepareId, oneTurnLog(), meta(prepareId))
+      const immediateGet = vi.spyOn(ctx.sessions, 'get').mockReturnValue(immediatelyLive)
+      await expect(coordinator.prepare(prepareId)).rejects.toThrow(/while it is live/)
+      immediateGet.mockRestore()
+
       const prepareLive = Session.create(prepareId, oneTurnLog(), meta(prepareId))
       const prepareGet = vi.spyOn(ctx.sessions, 'get')
         .mockReturnValueOnce(undefined)
@@ -1281,7 +1364,7 @@ describe('PersistenceCoordinator observation cancellation', () => {
     }
   })
 
-  it('readFrom via the seek hook: serves the suffix, maps undefined to not-found, and relays hook failures by abort state', async () => {
+  it('readFrom via the source reader: serves the suffix, reports absence, and relays reader failures by abort state', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     const backend = new ControlledBackend()
@@ -1302,7 +1385,7 @@ describe('PersistenceCoordinator observation cancellation', () => {
       }
       const suffix = await coordinator.readFrom(id, 3)
       expect(suffix.events).toEqual(log.slice(3))
-      // The hook's `undefined` is the backend contract's not-found result.
+      // Absence is established while opening the source, before an event read.
       await expect(coordinator.readFrom(SessionId('missing-seek'), 0)).rejects.toThrow('not found')
 
       // A hook failure with no cancellation in play propagates as-is.
@@ -1310,6 +1393,20 @@ describe('PersistenceCoordinator observation cancellation', () => {
       backend.seekHook = () => Promise.reject(hookFailure)
       await expect(coordinator.readFrom(id, 0)).rejects.toBe(hookFailure)
 
+      // A revision conflict is retryable because it names no stable source.
+      let conflictAttempts = 0
+      backend.seekHook = async (hookId, fromSeq) => {
+        conflictAttempts += 1
+        if (conflictAttempts === 1) {
+          throw new SessionPersistenceRevisionConflictError('source changed during readFrom')
+        }
+        const entry = backend.store.get(hookId)
+        if (entry === undefined) return undefined
+        return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) }
+      }
+      await expect(coordinator.readFrom(id, 2)).resolves.toMatchObject({ events: log.slice(2) })
+      expect(conflictAttempts).toBe(2)
+
       // A hook failure after cancellation surfaces the caller's abort reason,
       // not the backend's internal teardown error. The abort fires only once
       // the hook is provably entered, so the failure exercises the catch (not
@@ -1439,14 +1536,13 @@ describe('PersistenceCoordinator retirement', () => {
       }, { inject: ['sessions'] }))
       await ctx.sessions.flush(first)
 
-      // Occupy the per-id serialize chain with a gated physical read:
+      // Occupy the per-id serialize chain with a gated source open:
       // inspect() correctly borrows the still-live Session without entering
       // the backend chain, while both retirements must queue behind readFrom().
       const readEntered = Promise.withResolvers<undefined>()
-      backend.seekHook = async () => {
+      backend.beforeLoadStored = async () => {
         readEntered.resolve(undefined)
         await readGate.promise
-        return undefined
       }
       const parked = coordinator.readFrom(id, 0).catch((error: unknown) => error)
       await readEntered.promise
@@ -1470,7 +1566,7 @@ describe('PersistenceCoordinator retirement', () => {
       // delete the successor's entry (exact-entry guard); the successor's own
       // forget() then clears the map.
       readGate.resolve(true)
-      expect(await parked).toBeInstanceOf(Error) // the parked inspect (not found) is observed
+      expect(await parked).toBeInstanceOf(Error) // the parked read (not found) is observed
       await firstRetirement
       await vi.waitFor(() => { expect(internals.retirements.has(id)).toBe(false) })
     } finally {
@@ -1879,6 +1975,235 @@ describe('SessionPersistence service registration', () => {
     await Promise.allSettled([fiber.dispose()])
   })
 
+  it('rejects obsolete event variants passed directly to the persistence writer', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const backend = new ControlledBackend()
+    let coordinator!: PersistenceCoordinator<never>
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      coordinator = new PersistenceCoordinator(inner, backend)
+    }, { inject: ['sessions'] }))
+    const id = SessionId('legacy-direct-append')
+    await coordinator.create(meta(id))
+
+    try {
+      await expect(coordinator.append(id, [legacyHeaderDelta()]))
+        .rejects.toThrow(/unsupported legacy request\/header-delta event/)
+      await expect(coordinator.append(id, [legacyModeSet()]))
+        .rejects.toThrow(/unsupported legacy mode\/set event/)
+      await expect(coordinator.append(id, [legacyFallbackHeader()]))
+        .rejects.toThrow(/unsupported legacy request\/header reason "fallback"/)
+      expect(backend.store.has(id)).toBe(false)
+    } finally {
+      await fiber.dispose()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('retries cold preparation when its physical source revision changes', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const backend = new ControlledBackend()
+    const id = SessionId('prepare-source-conflict')
+    backend.store.set(id, { meta: meta(id), events: oneTurnLog() })
+    let attempts = 0
+    backend.seekHook = async (hookId, fromSeq) => {
+      attempts += 1
+      if (attempts === 1) throw new SessionPersistenceRevisionConflictError('prepare source changed')
+      const entry = backend.store.get(hookId)
+      if (entry === undefined) return undefined
+      return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) }
+    }
+    let coordinator!: PersistenceCoordinator<never>
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      coordinator = new PersistenceCoordinator(inner, backend)
+    }, { inject: ['sessions'] }))
+
+    try {
+      await expect(coordinator.inspect(id)).resolves.toMatchObject({ events: oneTurnLog() })
+      expect(attempts).toBe(2)
+    } finally {
+      await fiber.dispose()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('retries live-prefix adoption when the physical source revision changes', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const backend = new ControlledBackend()
+    const id = SessionId('hmr-source-conflict')
+    const m = meta(id, '/work')
+    backend.store.set(id, { meta: m, events: oneTurnLog() })
+    const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } })
+    let attempts = 0
+    backend.seekHook = async (hookId, fromSeq) => {
+      attempts += 1
+      if (attempts === 1) throw new SessionPersistenceRevisionConflictError('live source changed')
+      const entry = backend.store.get(hookId)
+      if (entry === undefined) return undefined
+      return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) }
+    }
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      new PersistenceCoordinator(inner, backend)
+    }, { inject: ['sessions'] }))
+
+    try {
+      await expect(ctx.sessions.flush(session)).resolves.toBe(true)
+      expect(attempts).toBe(2)
+    } finally {
+      await fiber.dispose()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('retries ownerless seed verification when the physical source revision changes', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const backend = new ControlledBackend()
+    const id = SessionId('seed-source-conflict')
+    const m = meta(id, '/work')
+    backend.store.set(id, { meta: m, events: oneTurnLog() })
+    let coordinator!: PersistenceCoordinator<never>
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      coordinator = new PersistenceCoordinator(inner, backend)
+    }, { inject: ['sessions'] }))
+
+    try {
+      await coordinator.load(id)
+      let attempts = 0
+      backend.seekHook = async (hookId, fromSeq) => {
+        attempts += 1
+        if (attempts === 1) throw new SessionPersistenceRevisionConflictError('seed source changed')
+        const entry = backend.store.get(hookId)
+        if (entry === undefined) return undefined
+        return { meta: entry.meta, events: entry.events.filter(event => event.seq >= fromSeq) }
+      }
+      const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } })
+
+      await expect(ctx.sessions.flush(session)).resolves.toBe(true)
+      expect(attempts).toBe(2)
+    } finally {
+      await fiber.dispose()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('streams an old-format prepared source into replacement and propagates non-conflict failures', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const backend = new ControlledBackend()
+    const id = SessionId('prepared-format-replacement')
+    const m = meta(id)
+    backend.store.set(id, { meta: m, events: oneTurnLog() })
+    let coordinator!: PersistenceCoordinator<never>
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      coordinator = new PersistenceCoordinator(inner, backend)
+    }, { inject: ['sessions'] }))
+    const source = {
+      inspection: Object.freeze({ meta: m, events: Object.freeze(oneTurnLog()) }),
+      session: Session.create(id, oneTurnLog(), m),
+      revision: memoryRevision(backend.store.get(id)!),
+      sourceVersion: -1,
+      sessionLength: oneTurnLog().length,
+      tornMarker: undefined,
+      closers: [],
+    }
+    const internals = coordinator as unknown as {
+      commitPrepared(value: typeof source): Promise<unknown>
+    }
+    const replace = vi.spyOn(backend, 'replaceStored')
+
+    try {
+      await expect(internals.commitPrepared(source)).resolves.toBeUndefined()
+      expect(replace).toHaveBeenCalledOnce()
+      expect(backend.store.get(id)?.events).toEqual(oneTurnLog())
+
+      const failure = new Error('replacement backend failed')
+      replace.mockRejectedValueOnce(failure)
+      source.revision = memoryRevision(backend.store.get(id)!)
+      await expect(internals.commitPrepared(source)).rejects.toBe(failure)
+
+      replace.mockRejectedValueOnce(new SessionPersistenceRevisionConflictError('replacement raced'))
+      await expect(internals.commitPrepared(source)).resolves.toBeUndefined()
+    } finally {
+      await fiber.dispose()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('routes live adoption of a decoded old format through the same replacement primitive', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const backend = new ControlledBackend()
+    const id = SessionId('live-format-replacement')
+    const m = meta(id, '/work')
+    const log = oneTurnLog()
+    backend.store.set(id, { meta: m, events: log })
+    let coordinator!: PersistenceCoordinator<never>
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      coordinator = new PersistenceCoordinator(inner, backend)
+    }, { inject: ['sessions'] }))
+    const revision = memoryRevision(backend.store.get(id)!)
+    const stored: StoredSessionSource<never> = {
+      meta: m,
+      revision,
+      readEvents: () => storedRead(async () => ({ events: log })),
+    }
+    const decoded = {
+      meta: m,
+      sourceVersion: -1,
+      revision,
+      events: (async function* (): AsyncIterable<SessionEvent> { yield* log })(),
+      completed: Promise.resolve({}),
+    }
+    const decode = vi.spyOn(formatDecoder, 'decodeStoredSession').mockReturnValue(decoded)
+    const replace = vi.spyOn(backend, 'replaceStored')
+    const internals = coordinator as unknown as {
+      adoptLivePrefix(
+        session: Session,
+        seed: readonly SessionEvent[],
+        source: StoredSessionSource<never>,
+      ): Promise<boolean>
+    }
+
+    try {
+      const session = Session.create(id, log, m)
+      await expect(internals.adoptLivePrefix(session, log, stored)).resolves.toBe(false)
+      expect(replace).toHaveBeenCalledOnce()
+      expect(backend.store.get(id)?.events).toEqual(log)
+    } finally {
+      decode.mockRestore()
+      await fiber.dispose()
+      await ctx.fiber.dispose()
+    }
+  })
+
+  it('propagates a non-conflict failure during ownerless seed verification', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    const backend = new ControlledBackend()
+    const id = SessionId('seed-source-failure')
+    const m = meta(id, '/work')
+    backend.store.set(id, { meta: m, events: oneTurnLog() })
+    let coordinator!: PersistenceCoordinator<never>
+    const fiber = await ctx.plugin(Object.assign((inner: Context) => {
+      coordinator = new PersistenceCoordinator(inner, backend)
+    }, { inject: ['sessions'] }))
+
+    try {
+      await coordinator.load(id)
+      const failure = new Error('seed reader failed')
+      backend.seekHook = () => Promise.reject(failure)
+      const session = ctx.sessions.create(id, { seed: oneTurnLog(), meta: { cwd: '/work' } })
+
+      await expect(ctx.sessions.flush(session)).rejects.toBe(failure)
+    } finally {
+      await Promise.allSettled([fiber.dispose()])
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('rejects a stored legacy fallback header during load', async () => {
     const id = SessionId('legacy-fallback-load')
     const m = meta(id, '/legacy')