Преглед на файлове

feat(session-format): migrate V2 prompts into V3 system surfaces

Stream an empty protected head after the first step and replace it before changed or cleared request headers, without moving source events. Remap only audited local sequence references, derive inherited cuts after upstream cardinality changes, and preserve generation-qualified captures and message identities.

Refuse pre-step surfaces and out-of-step prompt changes rather than invent lifecycle events. Reject unclassified migration payloads and detect deterministic generated-ID collisions in either source order. Native V3 accepts empty and in-history system messages, protects only the head, and reuses frozen relationships through a private nonescaping projection; historical repair IDs remain opaque.

Keep released codecs untouched and distinguish migration admission from native extension admission. V3 structural payload preflight cannot disappear behind recoverable row corruption. Validation: 35 focused tests, host TypeScript build, focused Oxlint, documentation quick gates and paired README recording. Parent owns installed-core/catalog/persistence integration and lockfile propagation.
Tianyi Cui преди 2 седмици
родител
ревизия
e04cfc4c87

+ 2 - 2
packages/session/session-format-v2-to-v3/README.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 packages/session/session-format-v2-to-v3/README.md
-README.md: 704c2391e2bbe3d9adef50722f83faf34431f5ee
-README.zh.md: 91254f1addba0056dbf1ea073d71e5211dbf4e4d
+README.md: 231987d44f79253464f809eb68a8673011e01acd
+README.zh.md: 81459f560013031529e2df48d711415239961048

+ 9 - 6
packages/session/session-format-v2-to-v3/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Restore released-v2 Session logs as v3 without changing their events."
+description: "Restore released-v2 system prompts as protected v3 messages while preserving historical requests."
 kind: "package-library"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-This library restores released-v2 Session records as v3 while preserving event payloads, sequence references, timestamps, ordering, and inherited prefixes. Persistence consumes it through the static Session format catalog. It does not publish or modify durable files.
+This library restores released-v2 system prompts as protected v3 messages while preserving historical request meaning, source-event chronology, timestamps, message identities, and inherited ownership. Persistence consumes it through the static Session format catalog. It does not publish or modify durable files.
 
 ## Table of Contents
 
@@ -35,7 +35,7 @@ Use the [catalog](../session-format-catalog/README.md) for restoration. Direct i
 const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 ```
 
-The header version becomes 3; all other header fields remain unchanged. The stage forwards events and compact runs synchronously, but refuses source delivery markers whose `sessionFormatVersion` is 3 because promotion would activate an unconfirmed target-generation watermark. Scalar inherited end-seed markers determine the exact cut at EOF. V3 record encoding and validation reuse the frozen released-v2 implementation without modifying it. Unknown required events remain refusals; installed event types and ignorable unknown events retain their admission rules.
+Session metadata changes only its version to 3. The stage inserts an empty `system/message` immediately after the first `step/start`, then replaces that protected head before each changed `request/header` prompt, including clears. It removes `header.system` from every request header without moving any source event. Metadata-only logs gain no head. V3 encoding and restoration accept empty system heads and reject retired `header.system`.
 
 -----
 
@@ -45,7 +45,9 @@ The header version becomes 3; all other header fields remain unchanged. The stag
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The [stage](src/migration.ts) tracks the inherited cut and source delivery ownership without changing event values. The [codec](src/codec.ts) changes physical header versions and shares v2 record encoding. The [validator](src/validation.ts) checks the v3 version before applying released-v2 event validation. No runtime invariant companion is published because this library owns no independently observable runtime registrations or state replicas.
+The [stage](src/migration.ts) emits synchronously, retains coordinate mappings, message identity sets, and current prompt/lifecycle state, and expands compact runs incrementally. It derives the target inherited cut from the last inherited end-seed marker, including when an upstream stage cannot supply a cut before EOF. The [reference mapper](src/references.ts) remaps local envelope provenance/replacement ranges, command source references, compaction ranges/lists, and title message lists. Delivery watermarks, session-reference capture coordinates, workflow counters, embedded model input, and message IDs retain their original meaning. V2 delivery markers claiming V3 acceptance are rejected.
+
+The [validator](src/validation.ts) checks system payloads, open-step ownership, and protected-head operations independently. Native V3 also admits in-history system appends, non-head replacements, and compaction of non-head system nodes. It reuses frozen ordinary relationship validation through a private view, preserving original events and IDs in the result; generated repair-ID suffixes remain historical identities, not current sequence coordinates. The [codec](src/codec.ts) shares frozen V2 physical envelope/provenance encoding. No runtime invariant companion is published because this library owns no independently observable runtime registrations or state replicas.
 
 </details>
 
@@ -66,7 +68,7 @@ The [stage](src/migration.ts) tracks the inherited cut and source delivery owner
 
 #### What the model sees
 
-`sessionFormatV2ToV3` preserves every model-visible event and its payload.
+`sessionFormatV2ToV3` preserves prompt text and ordinary messages at each historical request. Empty heads produce no model message.
 
 #### Token effect
 
@@ -81,7 +83,8 @@ The model-message prefix remains unchanged.
 <a id="known-limitations-and-deferred-work"></a>
 
 - **No file publication** — persistence owns immutable successor publication; this package never overwrites released generations.
-- **Identity conversion only** — the stage introduces no structural event transformations.
+- **Chronology-preserving inputs** — a surface event before the first step, or a changed prompt outside an open step, is refused with `SessionFormatUnsupportedMigrationError`; moving events or inventing out-of-step system messages would violate reconstruction.
+- **Audited migration vocabulary** — V2 events and the installed message-feedback additions are classified explicitly. Unknown events, even ignorable ones, and unknown message-source or content kinds are refused during migration because sequence dependencies cannot be inferred. Native equal-version reads retain ordinary ignorable-event admission.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 9 - 6
packages/session/session-format-v2-to-v3/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "将已发布的 v2 Session 日志恢复为 v3,保留所有事件。"
+description: "将已发布的 v2 系统提示恢复为受保护的 v3 消息,保留历史请求含义。"
 kind: "package-library"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-library"
 
 ## 概述
 
-本库将已发布的 v2 Session 记录恢复为 v3,保留事件载荷、序列引用、时间戳、顺序和继承前缀。持久化通过静态 Session 格式目录使用本库。本库不发布或修改持久化文件。
+本库将已发布的 v2 系统提示恢复为受保护的 v3 消息,保留历史请求含义、源事件时序、时间戳、消息身份与继承归属。持久化通过静态 Session 格式目录使用本库。本库不发布或修改持久化文件。
 
 ## 目录
 
@@ -35,7 +35,7 @@ kind: "package-library"
 const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 ```
 
-头部版本变为 3,其余头部字段保持不变。阶段同步转发事件和紧凑事件段,但拒绝 `sessionFormatVersion` 为 3 的源投递标记,因为升级会激活未经确认的目标代际水位。标量继承 end-seed 标记在 EOF 确定精确切点。V3 记录编码和校验复用冻结的已发布 v2 实现,不修改该实现。未知必需事件仍被拒绝;已安装事件类型和可忽略的未知事件保留其准入规则。
+Session 元数据仅将版本改为 3。阶段在首个 `step/start` 后立即插入空 `system/message`,随后在每次提示发生变化的 `request/header` 前替换受保护的头节点,包括清空提示。每个请求头的 `header.system` 均被移除,不移动任何源事件。仅含元数据的日志不添加头节点。V3 编码和恢复接受空系统头节点,并拒绝已退役的 `header.system`。
 
 -----
 
@@ -45,7 +45,9 @@ const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 <details>
 <summary>实现细节 — 点击展开</summary>
 
-[阶段](src/migration.ts)跟踪继承切点和源投递归属,不改变事件值。[编解码器](src/codec.ts)改变物理头部版本并共享 v2 记录编码。[校验器](src/validation.ts)先检查 v3 版本,再执行已发布 v2 事件校验。本库不拥有可独立观察的运行时注册或状态副本,因此不发布运行时不变量伴随入口。
+[阶段](src/migration.ts)同步输出,保留坐标映射、消息身份集合与当前提示、生命周期状态,并增量展开紧凑事件段。它从最后一个继承 end-seed 标记推导目标继承切点,包括上游阶段在 EOF 前无法提供切点的情况。[引用映射器](src/references.ts)重映射本地信封溯源与替换范围、命令源引用、压缩范围与列表,以及标题消息列表。投递水位、会话引用捕获坐标、工作流计数器、嵌入的模型输入与消息 ID 保留原有含义。声称已获 V3 接收的 V2 投递标记会被拒绝。
+
+[校验器](src/validation.ts)独立检查系统载荷、开放步骤归属与受保护的头节点操作。原生 V3 也接受历史内系统消息追加、非头节点替换,以及非头系统节点的压缩。它通过私有视图复用冻结的普通关系校验,在结果中保留原始事件与 ID;生成的修复 ID 后缀仍是历史身份,而非当前序列坐标。[编解码器](src/codec.ts)共享冻结的 V2 物理信封与溯源编码。本库不拥有可独立观察的运行时注册或状态副本,因此不发布运行时不变量伴随入口。
 
 </details>
 
@@ -66,7 +68,7 @@ const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 
 #### 模型看到什么
 
-`sessionFormatV2ToV3` 保留每个模型可见事件及其载荷。
+`sessionFormatV2ToV3` 在每个历史请求处保留提示文本与普通消息。空头节点不产生模型消息。
 
 #### Token 影响
 
@@ -81,7 +83,8 @@ const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
 <a id="known-limitations-and-deferred-work"></a>
 
 - **不发布文件** — 持久化负责不可变后继代的发布;本包绝不覆盖已发布代。
-- **仅恒等转换** — 阶段不引入结构性事件转换。
+- **保持时序的输入** — 首个步骤前出现表面事件,或在开放步骤之外更改提示时,以 `SessionFormatUnsupportedMigrationError` 拒绝;移动事件或虚构步骤外的系统消息会破坏重建。
+- **经过审计的迁移词汇** — 显式分类 V2 事件与已安装的消息反馈扩展。迁移拒绝未知事件(即使标为可忽略)及未知消息来源或内容种类,因为无法推断其序列依赖。原生同版本读取保留普通可忽略事件的准入规则。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 2 - 1
packages/session/session-format-v2-to-v3/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-session-format-v2-to-v3",
-  "description": "Released-v2 Session codec and identity streaming migration to v3",
+  "description": "Streaming migration of released-v2 system prompts into the v3 protected message head",
   "version": "0.1.3-alpha.1",
   "publishConfig": {
     "access": "public"
@@ -40,6 +40,7 @@
   },
   "dependencies": {
     "@deepseek-ai/dsh-session-format": "workspace:^",
+    "@deepseek-ai/dsh-session-format-v0-to-v1": "workspace:^",
     "@deepseek-ai/dsh-session-format-v1-to-v2": "workspace:^"
   },
   "peerDependencies": {

+ 20 - 4
packages/session/session-format-v2-to-v3/src/codec.ts

@@ -1,4 +1,4 @@
-/** V3 record encoding shares the unchanged released-v2 physical event language. */
+/** V3 physical envelopes retain V2 provenance encoding and validate native system/header payloads. */
 
 import { SessionFormatError, isSessionFormatJsonObject, snapshotSessionFormatJson } from '@deepseek-ai/dsh-session-format'
 import type {
@@ -8,8 +8,9 @@ import type {
 } from '@deepseek-ai/dsh-session-format'
 import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2'
 import { assertReleasedV3Header } from './validation.ts'
+import { assertEvent, assertV3StructuralRow } from './payload.ts'
 
-/** Physical v3 codec: only the header version differs from released v2. */
+/** Physical V3 codec with protected system-message payload admission and retired header.system refusal. */
 export const releasedV3SessionFormatCodec = Object.freeze({
   version: 3,
   decodeHeader(value: unknown) {
@@ -17,7 +18,19 @@ export const releasedV3SessionFormatCodec = Object.freeze({
   },
   createDecoder(value, recovery) {
     const decoder = releasedV2SessionFormatCodec.createDecoder(v2PhysicalHeader(value), recovery)
-    return { ...decoder, header: { ...decoder.header, version: 3 } }
+    return {
+      ...decoder, header: { ...decoder.header, version: 3 },
+      decodeRow(row, context) {
+        assertV3StructuralRow(row)
+        decoder.decodeRow(row, {
+          emitEvent(event) {
+            if (event.type === 'system/message' || event.type === 'request/header') assertEvent(event, 3)
+            context.emitEvent(event)
+          },
+          emitRun: run => context.emitRun(run),
+        })
+      },
+    }
   },
   encodeHeader(header, inheritedEventCount) {
     assertReleasedV3Header(header)
@@ -26,7 +39,10 @@ export const releasedV3SessionFormatCodec = Object.freeze({
       version: 3,
     }
   },
-  encodeEvent: releasedV2SessionFormatCodec.encodeEvent,
+  encodeEvent(event) {
+    if (event.type === 'system/message' || event.type === 'request/header') assertEvent(event, 3)
+    return releasedV2SessionFormatCodec.encodeEvent(event)
+  },
 } satisfies SessionFormatCodec & SessionFormatCurrentEncoder)
 
 function v2PhysicalHeader(value: unknown): SessionFormatHeader {

+ 1 - 1
packages/session/session-format-v2-to-v3/src/index.ts

@@ -1,4 +1,4 @@
-/** Released-v2 physical codec and identity streaming migration into v3. */
+/** Released-v2 codec and streaming system-prompt migration into the V3 protected message head. */
 
 export { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2'
 export * from './codec.ts'

+ 100 - 35
packages/session/session-format-v2-to-v3/src/migration.ts

@@ -1,17 +1,14 @@
-/** Identity adjacent stage; events and compact runs retain their values and ordering. */
+/** Streaming promotion of request-header prompts into one protected system-message head. */
 
-import { SessionFormatError, defineSessionFormatMigration, isSessionFormatJsonObject, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
-import type {
-  SessionFormatEvent,
-  SessionFormatEventRun,
-  SessionFormatMigrationContext,
-  SessionFormatMigrationStage,
-  SessionFormatMigrationStageInput,
-} from '@deepseek-ai/dsh-session-format'
+import { createHash } from 'node:crypto'
+import { SessionFormatError, SessionFormatUnsupportedMigrationError, defineSessionFormatMigration, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
+import type { SessionFormatEvent, SessionFormatEventRun, SessionFormatMigrationContext, SessionFormatMigrationStage, SessionFormatMigrationStageInput } from '@deepseek-ai/dsh-session-format'
 import { assertReleasedV2Header } from '@deepseek-ai/dsh-session-format-v1-to-v2'
+import { assertEvent, record, SURFACE_TYPES } from './payload.ts'
+import { remapEvent } from './references.ts'
 import { assertReleasedV3Header } from './validation.ts'
 
-/** Adjacent identity migration from released v2 to v3; refuses source delivery markers claiming v3 acceptance. */
+/** Adjacent structural migration; refuses unclassified payloads and prompts that cannot retain source chronology. */
 export const sessionFormatV2ToV3 = defineSessionFormatMigration({
   name: '@deepseek-ai/dsh-session-format-v2-to-v3',
   fromVersion: 2,
@@ -20,55 +17,123 @@ export const sessionFormatV2ToV3 = defineSessionFormatMigration({
     assertReleasedV2Header(header)
     return { ...header, version: 3 }
   },
-  createStage(input) {
-    return new ReleasedV2ToV3Stage(input)
-  },
+  createStage(input) { return new ReleasedV2ToV3Stage(input) },
   validateTargetHeader: assertReleasedV3Header,
 })
 
 class ReleasedV2ToV3Stage implements SessionFormatMigrationStage {
-  private inheritedEventCount: number | undefined
+  readonly headerInheritedEventCount?: number
+  private readonly mapping: number[] = []
+  private readonly originalIds = new Set<string>()
+  private readonly generatedIds = new Set<string>()
+  private targetSeq = 0
+  private sourceCut: number | undefined
+  private targetCut: number | undefined
   private lastForeignDeliverySeq: number | undefined
+  private step: { turn: number; step: number } | undefined
+  private head: number | undefined
+  private prompt = ''
 
   constructor(private readonly input: SessionFormatMigrationStageInput) {
-    this.inheritedEventCount = input.sourceHeader.isSeeded ? undefined : 0
+    assertReleasedV2Header(input.sourceHeader)
+    this.sourceCut = input.sourceHeader.isSeeded ? undefined : 0
+    this.targetCut = input.sourceHeader.isSeeded ? undefined : 0
+    if (!input.sourceHeader.isSeeded) this.headerInheritedEventCount = 0
   }
 
   transformEvent(event: SessionFormatEvent, context: SessionFormatMigrationContext): void {
-    if (event.type === 'session/end-seed'
-      && isSessionFormatJsonObject(event.data)
-      && event.data['inherited'] === true) {
-      this.inheritedEventCount = event.seq
+    if (event.seq !== this.mapping.length) throw new SessionFormatError('format v2 source events must be dense')
+    assertEvent(event, 2)
+    this.observeMessageIds(event)
+    let source = event
+    const data = record(event.data, event.type)
+    if (event.type === 'request/header') {
+      const { system, ...header } = record(data['header'], 'request header')
+      const prompt = typeof system === 'string' ? system : ''
+      if (prompt !== this.prompt) this.emitSystem(prompt, event, context)
+      source = { ...event, data: { ...data, header } }
+    }
+    if (SURFACE_TYPES.has(event.type) && this.head === undefined) {
+      throw new SessionFormatUnsupportedMigrationError('format v2 surface before first step cannot acquire a system head without changing chronology')
+    }
+    if (event.type === 'session/end-seed' && data['inherited'] === true) {
+      if (!this.input.sourceHeader.isSeeded) throw new SessionFormatError('format v2 unseeded Session contains an inherited end-seed marker')
+      this.sourceCut = event.seq
+      this.targetCut = this.targetSeq
     }
-    if (event.type === 'session-log-deepseek/delivery-accepted'
-      && isSessionFormatJsonObject(event.data)) {
-      if (event.data['sessionFormatVersion'] === 3) {
-        throw new SessionFormatError('format v2 delivery marker claims target format v3')
-      }
-      if (event.data['sessionFormatVersion'] === 2
-        && event.data['sessionId'] !== this.input.sourceHeader.id) {
-        this.lastForeignDeliverySeq = event.seq
-      }
+    if (event.type === 'session-log-deepseek/delivery-accepted') {
+      if (data['sessionFormatVersion'] === 3) throw new SessionFormatError('format v2 delivery marker claims target format v3')
+      if (data['sessionFormatVersion'] === 2 && data['sessionId'] !== this.input.sourceHeader.id) this.lastForeignDeliverySeq = event.seq
+    }
+    const target = remapEvent(source, this.targetSeq, this.mapping)
+    this.mapping.push(this.targetSeq++)
+    context.emitEvent(target)
+    if (event.type === 'step/start') {
+      this.step = { turn: data['turn'] as number, step: data['step'] as number }
+      if (this.head === undefined) this.emitSystem('', event, context)
+    } else if (event.type === 'step/end' || event.type === 'turn/end') {
+      this.step = undefined
     }
-    context.emitEvent(event)
   }
 
   transformRun(run: SessionFormatEventRun, context: SessionFormatMigrationContext): void {
-    context.emitRun(run)
+    for (const event of run.expand()) this.transformEvent(event, context)
   }
 
   finish(_context: SessionFormatMigrationContext): number {
-    const cut = sessionFormatCount(this.inheritedEventCount, 'format v2 inherited end-seed marker')
+    const cut = sessionFormatCount(this.sourceCut, 'format v2 inherited end-seed marker')
     if (this.input.sourceInheritedEventCount !== undefined && this.input.sourceInheritedEventCount !== cut) {
       throw new SessionFormatError('format v2 inherited end-seed marker disagrees with its source cut')
     }
-    if (!this.input.sourceHeader.isSeeded && cut !== 0) {
-      throw new SessionFormatError('format v2 unseeded Session contains an inherited end-seed marker')
-    }
+    if (!this.input.sourceHeader.isSeeded && this.targetCut !== 0) throw new SessionFormatError('format v2 unseeded Session contains an inherited end-seed marker')
     if (this.lastForeignDeliverySeq !== undefined
       && (this.input.sourceHeader.parentSession === undefined || this.lastForeignDeliverySeq >= cut)) {
       throw new SessionFormatError('current-generation delivery marker names the wrong Session')
     }
-    return cut
+    return sessionFormatCount(this.targetCut, 'format v3 inherited event count')
+  }
+
+  private observeMessageIds(event: SessionFormatEvent): void {
+    const data = record(event.data, event.type)
+    const messages = event.type === 'user/message' ? [data]
+      : event.type === 'assistant/message' || event.type === 'tool/result' ? [record(data['message'], 'message')]
+        : event.type === 'agent/inbox/spliced' ? data['inserted']
+          : event.type === 'session/title-llm-request' ? data['messages'] : []
+    if (!Array.isArray(messages)) return
+    for (const message of messages) {
+      const id = record(message, 'message')['id']
+      if (typeof id !== 'string') continue
+      if (this.generatedIds.has(id)) throw new SessionFormatUnsupportedMigrationError('source message id collides with a generated system message id')
+      this.originalIds.add(id)
+    }
+  }
+
+  private emitSystem(prompt: string, anchor: SessionFormatEvent, context: SessionFormatMigrationContext): void {
+    if (this.step === undefined) {
+      throw new SessionFormatUnsupportedMigrationError('format v2 changed request prompt outside an open step cannot retain source chronology')
+    }
+    const identity = JSON.stringify(['session-format-v2-to-v3', this.input.sourceHeader.id, anchor.seq, anchor.type])
+    const id = 'v2-to-v3-system-' + createHash('sha256').update(identity).digest('hex')
+    if (this.originalIds.has(id) || this.generatedIds.has(id)) {
+      throw new SessionFormatUnsupportedMigrationError('generated system message id collides with an existing message id')
+    }
+    this.generatedIds.add(id)
+    const seq = this.targetSeq++
+    context.emitEvent({
+      type: 'system/message', seq, time: anchor.time,
+      data: {
+        ...this.step,
+        message: {
+          id,
+          role: 'system', source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' },
+          content: prompt === '' ? [] : [{ type: 'text', text: prompt }],
+        },
+      },
+      ...(this.head === undefined
+        ? { surfaceOp: 'append' }
+        : { surfaceOp: { op: 'replace', start: this.head, end: this.head }, sourceEventSeqs: [this.head] }),
+    })
+    this.head = seq
+    this.prompt = prompt
   }
 }

+ 189 - 0
packages/session/session-format-v2-to-v3/src/payload.ts

@@ -0,0 +1,189 @@
+/** Audited V2 migration admission and V3 payload validation, independent of installed core Session types. */
+
+import { SessionFormatError, SessionFormatUnsupportedMigrationError, isSessionFormatJsonObject, sessionFormatCount, sessionFormatSafeInteger } from '@deepseek-ai/dsh-session-format'
+import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
+import { assertReleasedPayloadSemantics, assertReleasedSurfaceMetadata } from '@deepseek-ai/dsh-session-format-v0-to-v1'
+import { RELEASED_V2_EVENT_DISPOSITIONS } from '@deepseek-ai/dsh-session-format-v1-to-v2'
+
+/** Audited surface event names; all other admitted events are log-only. */
+export const SURFACE_TYPES: ReadonlySet<string> = new Set(['system/message', 'user/message', 'assistant/message', 'tool/result'])
+const SOURCE_KINDS = new Set(['user', 'plugin', 'model', 'tool', 'agent-instructions', 'session-reference', 'team-message', 'goal', 'skill-invocation', 'skill-catalog', 'coordinator', 'subagent-report', 'subagent-settled', 'webhook'])
+
+/**
+ * Require a JSON object at the durable input boundary.
+ * @param value - decoded value.
+ * @param label - diagnostic subject.
+ * @returns the narrowed object.
+ */
+export function record(value: SessionFormatJsonValue | undefined, label: string): SessionFormatJsonObject {
+  if (!isSessionFormatJsonObject(value)) throw new SessionFormatError(label + ' must be an object')
+  return value
+}
+
+/**
+ * Reject missing and unaudited members rather than guessing whether they contain coordinates.
+ * @param value - decoded record.
+ * @param required - required member names.
+ * @param optional - additional admitted names.
+ * @param label - diagnostic subject.
+ */
+export function keys(value: SessionFormatJsonObject, required: readonly string[], optional: readonly string[], label: string): void {
+  const missing = required.find(key => !Object.hasOwn(value, key))
+  const unexpected = Object.keys(value).find(key => !required.includes(key) && !optional.includes(key))
+  if (missing !== undefined) throw new SessionFormatError(label + ' lacks required field ' + missing)
+  if (unexpected !== undefined) throw new SessionFormatError(label + ' has unexpected field ' + unexpected)
+}
+
+/**
+ * Validate classified payloads before migration, or native V3 system/header payloads.
+ * @param event - decoded logical event.
+ * @param version - source or target generation.
+ */
+export function assertEvent(event: SessionFormatEvent, version: 2 | 3): void {
+  const system = version === 3 && event.type === 'system/message'
+  const disposition = RELEASED_V2_EVENT_DISPOSITIONS[event.type]
+  const feedback = event.type === 'feedback/message-put' || event.type === 'feedback/message-delete'
+  if (disposition === undefined && !system && !feedback) {
+    throw new SessionFormatUnsupportedMigrationError('format v2 to v3 cannot safely transform unclassified event ' + event.type)
+  }
+  const surface = SURFACE_TYPES.has(event.type)
+  keys(event, ['type', 'seq', 'time', 'data'], surface ? ['ignorable', 'sourceEventSeqs', 'surfaceOp'] : ['ignorable'], event.type)
+  sessionFormatCount(event.seq, 'event seq')
+  sessionFormatSafeInteger(event.time, 'event time')
+  if (event['ignorable'] !== undefined && event['ignorable'] !== true) throw new SessionFormatError('ignorable must be true')
+  if (surface) {
+    assertReleasedSurfaceMetadata(event, event.seq, event.type, 'forbid-assistant')
+    if (event['surfaceOp'] === undefined) throw new SessionFormatError(event.type + ' requires surfaceOp')
+  }
+  const data = record(event.data, event.type + ' data')
+  if (system) {
+    assertSystem(event, data)
+    return
+  }
+  if (feedback) {
+    assertFeedback(event.type, data)
+    return
+  }
+  if (disposition === undefined) throw new SessionFormatError('missing event disposition')
+  keys(data, disposition.required, disposition.optional, event.type + ' data')
+  if (version === 3 && event.type === 'request/header' && Object.hasOwn(record(data['header'], 'request header'), 'system')) {
+    throw new SessionFormatError('format v3 request/header rejects retired header.system')
+  }
+  assertReleasedPayloadSemantics(event, version)
+  if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
+    if (!Array.isArray(data['stream'])) throw new SessionFormatError('assistant stream must be an array')
+    for (const coordinate of ['turn', 'step']) {
+      if (sessionFormatCount(data[coordinate], coordinate) === 0) throw new SessionFormatError(coordinate + ' must be positive')
+    }
+  }
+  if (event.type === 'session/end-seed' && data['inherited'] !== undefined && data['inherited'] !== true) {
+    throw new SessionFormatError('session/end-seed inherited must be true')
+  }
+  // These are the only payload positions containing Harness messages. Tool JSON and stream
+  // records are owner-opaque; their counters and serialized text are not local Session refs.
+  if (version === 3) return
+  if (event.type === 'user/message') assertSource(data)
+  if (event.type === 'assistant/message' || event.type === 'tool/result') assertSource(record(data['message'], 'message'))
+  if (version === 2 && event.type === 'tool/result' && isSessionFormatJsonObject(data['error']) && data['error']['code'] === 'TOOL_NOT_STARTED') {
+    const message = record(data['message'], 'tool result message')
+    const source = record(message['source'], 'tool result source')
+    if (!isRepairIdentity(message['id'], source['callId'])) {
+      throw new SessionFormatError('TOOL_NOT_STARTED repair requires its canonical historical message id')
+    }
+  }
+  if (event.type === 'agent/inbox/spliced' || event.type === 'session/title-llm-request') {
+    const messages = data[event.type === 'agent/inbox/spliced' ? 'inserted' : 'messages']
+    if (Array.isArray(messages)) for (const message of messages) assertSource(record(message, 'message'))
+  }
+}
+
+/**
+ * Recognize stable generated repair IDs without interpreting their historical suffix as a current coordinate.
+ * @param id - durable message identity.
+ * @param callId - advertised tool identity.
+ * @returns whether the identity has the canonical historical repair form.
+ */
+export function isRepairIdentity(id: SessionFormatJsonValue | undefined, callId: SessionFormatJsonValue | undefined): boolean {
+  const prefix = 'interrupted-tool-result-' + callId + '-'
+  if (typeof id !== 'string' || !id.startsWith(prefix)) return false
+  const suffix = id.slice(prefix.length)
+  return /^(0|[1-9]\d*)$/.test(suffix) && Number.isSafeInteger(Number(suffix))
+}
+
+function assertSource(message: SessionFormatJsonObject): void {
+  const source = record(message['source'], 'message source')
+  if (typeof source['kind'] !== 'string' || !SOURCE_KINDS.has(source['kind'])) {
+    throw new SessionFormatUnsupportedMigrationError('cannot safely transform unclassified message source')
+  }
+  assertContentKinds(message['content'])
+}
+
+function assertContentKinds(content: SessionFormatJsonValue | undefined): void {
+  if (!Array.isArray(content)) throw new SessionFormatError('message content must be an array')
+  for (const value of content) {
+    const block = record(value, 'message content')
+    switch (block['type']) {
+      case 'text':
+      case 'reasoning':
+      case 'image':
+      case 'tool-call':
+        break
+      case 'tool-result':
+        assertContentKinds(block['content'])
+        break
+      default:
+        throw new SessionFormatUnsupportedMigrationError('cannot safely transform unclassified message content')
+    }
+  }
+}
+
+/**
+ * Reject V3 structural payload violations even beyond a recoverable physical-row failure.
+ * @param value - raw physical row; ordinary rows retain the frozen decoder's recovery policy.
+ */
+export function assertV3StructuralRow(value: unknown): void {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) return
+  const row = value as SessionFormatJsonObject
+  if (row['type'] === 'request/header') {
+    const data = record(row['data'], 'request/header data')
+    if (Object.hasOwn(record(data['header'], 'request header'), 'system')) {
+      throw new SessionFormatError('format v3 request/header rejects retired header.system')
+    }
+  } else if (row['type'] === 'system/message') {
+    const data = record(row['data'], 'system/message data')
+    assertSystem({ type: 'system/message', seq: 0, time: 0, data }, data)
+  }
+}
+
+function assertSystem(event: SessionFormatEvent, data: SessionFormatJsonObject): void {
+  keys(data, ['turn', 'step', 'message'], [], 'system/message data')
+  for (const coordinate of ['turn', 'step']) {
+    if (sessionFormatCount(data[coordinate], coordinate) === 0) throw new SessionFormatError(coordinate + ' must be positive')
+  }
+  const message = record(data['message'], 'system message')
+  keys(message, ['id', 'role', 'source', 'content'], [], 'system message')
+  if (typeof message['id'] !== 'string' || message['id'].length === 0 || message['role'] !== 'system') {
+    throw new SessionFormatError('system message requires an id and system role')
+  }
+  const source = record(message['source'], 'system source')
+  if (source['kind'] !== 'plugin' || typeof source['plugin'] !== 'string' || source['plugin'].length === 0) {
+    throw new SessionFormatError('system message requires plugin source')
+  }
+  assertReleasedPayloadSemantics({ ...event, type: 'user/message', data: { ...message, role: 'user' } }, 3)
+}
+
+function assertFeedback(type: string, data: SessionFormatJsonObject): void {
+  keys(data, type === 'feedback/message-put' ? ['sessionId', 'item'] : ['sessionId', 'messageId'], [], type)
+  if (typeof data['sessionId'] !== 'string') throw new SessionFormatError('feedback sessionId must be a string')
+  if (type === 'feedback/message-delete') {
+    if (typeof data['messageId'] !== 'string') throw new SessionFormatError('feedback messageId must be a string')
+    return
+  }
+  const item = record(data['item'], 'feedback item')
+  keys(item, ['messageId', 'rating', 'version', 'createdAt', 'updatedAt'], ['note'], 'feedback item')
+  for (const key of ['messageId', 'version']) if (typeof item[key] !== 'string') throw new SessionFormatError('feedback ' + key + ' must be a string')
+  if (item['rating'] !== 'positive' && item['rating'] !== 'negative') throw new SessionFormatError('invalid feedback rating')
+  if (item['note'] !== undefined && typeof item['note'] !== 'string') throw new SessionFormatError('feedback note must be a string')
+  sessionFormatCount(item['createdAt'], 'feedback createdAt')
+  sessionFormatCount(item['updatedAt'], 'feedback updatedAt')
+}

+ 51 - 0
packages/session/session-format-v2-to-v3/src/references.ts

@@ -0,0 +1,51 @@
+/** Explicit local-coordinate remapping; captured generations and owner-local counters remain opaque. */
+
+import { SessionFormatError, sessionFormatCount } from '@deepseek-ai/dsh-session-format'
+import type { SessionFormatEvent, SessionFormatJsonObject, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
+import { record } from './payload.ts'
+
+/**
+ * Remap only audited same-artifact references, preserving IDs and embedded model input.
+ * @param event - validated source event.
+ * @param seq - output event position.
+ * @param mapping - earlier source positions mapped to output positions.
+ * @returns the event in target coordinates.
+ */
+export function remapEvent(event: SessionFormatEvent, seq: number, mapping: readonly number[]): SessionFormatEvent {
+  const one = (value: SessionFormatJsonValue | undefined): number => {
+    const source = sessionFormatCount(value, 'source event reference')
+    const target = mapping[source]
+    if (source >= event.seq || target === undefined) throw new SessionFormatError('reference must name an earlier source event')
+    return target
+  }
+  const list = (value: SessionFormatJsonValue | undefined): number[] => {
+    if (!Array.isArray(value)) throw new SessionFormatError('sequence references must be an array')
+    return value.map(one)
+  }
+  const range = (value: SessionFormatJsonValue | undefined): SessionFormatJsonObject => {
+    const source = record(value, 'sequence range')
+    return { ...source, start: one(source['start']), end: one(source['end']) }
+  }
+  let data = record(event.data, event.type)
+  switch (event.type) {
+    case 'command/done':
+      if (data['sourceEventSeq'] !== undefined) data = { ...data, sourceEventSeq: one(data['sourceEventSeq']) }
+      break
+    case 'compaction/summary':
+    case 'compaction/prune':
+      data = { ...data, shadowedRange: range(data['shadowedRange']), shadowedSeqs: list(data['shadowedSeqs']) }
+      break
+    case 'session/title':
+    case 'session/title-llm-request':
+      data = { ...data, messageSeqs: list(data['messageSeqs']) }
+      break
+    // Delivery watermarks and session-reference captures identify their original generation.
+    // Workflow seq, stream block indices, turn/step, and numeric tool JSON are not Session seqs.
+  }
+  return {
+    ...event, seq, data,
+    ...(event['sourceEventSeqs'] === undefined ? {} : { sourceEventSeqs: list(event['sourceEventSeqs']) }),
+    ...(event['surfaceOp'] === undefined || event['surfaceOp'] === 'append'
+      ? {} : { surfaceOp: range(event['surfaceOp']) }),
+  }
+}

+ 69 - 8
packages/session/session-format-v2-to-v3/src/validation.ts

@@ -1,8 +1,9 @@
-/** V3 validation preserves the released-v2 event model. */
+/** Native V3 system-head validation with a private view for frozen non-system relationships. */
 
 import { SessionFormatError } from '@deepseek-ai/dsh-session-format'
-import type { SessionFormatArtifact, SessionFormatHeader } from '@deepseek-ai/dsh-session-format'
+import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatHeader } from '@deepseek-ai/dsh-session-format'
 import { assertReleasedV2Header, restoreReleasedV2Artifact } from '@deepseek-ai/dsh-session-format-v1-to-v2'
+import { assertEvent, isRepairIdentity, record, SURFACE_TYPES } from './payload.ts'
 
 /**
  * Validate v3 logical metadata with the released-v2 fields.
@@ -14,16 +15,76 @@ export function assertReleasedV3Header(header: SessionFormatHeader): void {
 }
 
 /**
- * Validate v3 event admission, relationships, and inherited cut without copying events.
+ * Validate system ownership, protected-head operations, ordinary relationships, and inherited cut.
+ * The private relationship view never escapes; the returned artifact and its messages are unchanged.
  * @param artifact - detached v3 artifact.
  * @param knownEventTypes - event types understood by the installed Session package.
  * @returns the same validated artifact.
  */
-export function restoreReleasedV3Artifact(
-  artifact: SessionFormatArtifact,
-  knownEventTypes: ReadonlySet<string>,
-): SessionFormatArtifact {
+export function restoreReleasedV3Artifact(artifact: SessionFormatArtifact, knownEventTypes: ReadonlySet<string>): SessionFormatArtifact {
   assertReleasedV3Header(artifact.header)
-  restoreReleasedV2Artifact({ ...artifact, header: { ...artifact.header, version: 2 } }, knownEventTypes, 3)
+  let step: { turn: unknown; step: unknown } | undefined
+  let head: number | undefined
+  let hasSurface = false
+  const events = artifact.events.map((event): SessionFormatEvent => {
+    const system = event.type === 'system/message'
+    if (system || event.type === 'request/header') assertEvent(event, 3)
+    if (event.type === 'step/start') {
+      const data = record(event.data, event.type)
+      step = { turn: data['turn'], step: data['step'] }
+    } else if (event.type === 'step/end' || event.type === 'turn/end') step = undefined
+    if (system) {
+      const data = record(event.data, 'system/message')
+      if (step === undefined || step.turn !== data['turn'] || step.step !== data['step']) {
+        throw new SessionFormatError('system/message does not match an open step')
+      }
+      const operation = event['surfaceOp']
+      if (hasSurface && head === undefined) throw new SessionFormatError('system/message requires a protected first surface head')
+      if (operation === 'append') {
+        if (!hasSurface) head = event.seq
+      } else {
+        const replace = record(operation, 'system replacement')
+        if (replace['start'] === head || replace['end'] === head) {
+          if (replace['start'] !== head || replace['end'] !== head) {
+            throw new SessionFormatError('system/message must replace exactly the current system head')
+          }
+          head = event.seq
+        }
+      }
+    } else if (SURFACE_TYPES.has(event.type) && event['surfaceOp'] !== 'append') {
+      const replace = record(event['surfaceOp'], 'surface replacement')
+      if (replace['start'] === head || replace['end'] === head) throw new SessionFormatError('surface replacement cannot shadow the protected system head')
+    }
+    if (event.type === 'compaction/prune' || event.type === 'compaction/summary') {
+      const data = record(event.data, event.type)
+      const seqs = data['shadowedSeqs']
+      if (Array.isArray(seqs) && seqs.some(seq => seq === head)) {
+        throw new SessionFormatError('compaction cannot shadow the protected system head')
+      }
+    }
+    if (SURFACE_TYPES.has(event.type)) hasSurface = true
+    return relationshipEvent(event)
+  })
+  restoreReleasedV2Artifact({ ...artifact, header: { ...artifact.header, version: 2 }, events }, knownEventTypes, 3)
   return artifact
 }
+
+function relationshipEvent(event: SessionFormatEvent): SessionFormatEvent {
+  if (event.type === 'system/message') {
+    const message = record(record(event.data, 'system data')['message'], 'system message')
+    // The frozen validator needs a surface-eligible event, not a model-visible substitute.
+    return { ...event, type: 'user/message', data: { ...message, role: 'user' } }
+  }
+  if (event.type !== 'tool/result') return event
+  const data = record(event.data, 'tool result')
+  if (data['error'] === undefined) return event
+  const error = record(data['error'], 'tool error')
+  if (error['code'] !== 'TOOL_NOT_STARTED') return event
+  const message = record(data['message'], 'tool message')
+  const source = record(message['source'], 'tool source')
+  const prefix = 'interrupted-tool-result-' + source['callId'] + '-'
+  const id = message['id']
+  if (!isRepairIdentity(id, source['callId'])) return event
+  // Message identity survives promotion; only this private frozen repair check uses target seq.
+  return { ...event, data: { ...data, message: { ...message, id: prefix + event.seq } } }
+}

+ 286 - 122
packages/session/session-format-v2-to-v3/tests/migration.spec.ts

@@ -1,141 +1,305 @@
-import { describe, expect, it, vi } from 'vitest'
-import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
-import type { SessionFormatEvent, SessionFormatHeader } from '@deepseek-ai/dsh-session-format'
-import {
-  assertReleasedV3Header,
-  releasedV2SessionFormatCodec,
-  releasedV3SessionFormatCodec,
-  restoreReleasedV3Artifact,
-  sessionFormatV2ToV3,
-} from '../src/index.ts'
-
-const header: SessionFormatHeader = {
-  version: 2, id: 'identity', createdAt: 1, isSeeded: false, delegationDepth: 0,
-}
+import { describe, expect, it } from 'vitest'
+import { createSessionFormatCatalog, SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
+import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
+import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
+import { sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
+import { assertReleasedV3Header, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec, restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '../src/index.ts'
 
-describe('v2 to v3 identity migration', () => {
-  it('changes only the header version and forwards events and runs without expansion', () => {
-    const target = sessionFormatV2ToV3.migrateHeader(header)
-    expect(target).toEqual({ ...header, version: 3 })
-    expect(header.version).toBe(2)
-    const stage = sessionFormatV2ToV3.createStage({
-      sourceHeader: header, targetHeader: target, sourceInheritedEventCount: 0, sourceKind: 'decoded',
-    })
-    const event: SessionFormatEvent = {
-      type: 'external/event', seq: 5, time: 12, data: { nested: ['payload'] },
-      ignorable: true, sourceEventSeqs: [1, 2], surfaceOp: { replace: [3] },
+const header: SessionFormatHeader = { version: 2, id: 'identity', createdAt: 1, isSeeded: false, delegationDepth: 0 }
+const request = (system?: string) => ({ header: { config: { provider: 'mock', model: 'mock' }, ...(system === undefined ? {} : { system }) }, reason: 'initial' })
+const user = (id = 'user') => ({ role: 'user', id, source: { kind: 'user' }, content: [{ type: 'text', text: id }] })
+const event = (type: string, data: SessionFormatEvent['data'], surfaceOp?: SessionFormatEvent['surfaceOp']): SessionFormatEvent => ({ type, seq: 0, time: 42, data, ...(surfaceOp === undefined ? {} : { surfaceOp }) })
+const dense = (events: readonly SessionFormatEvent[]) => events.map((e, seq) => ({ ...e, seq }))
+const opening = () => [event('turn/start', { turn: 1 }), event('step/start', { turn: 1, step: 1 })]
+function stage(source = header, sourceCut?: number) {
+  const target = sessionFormatV2ToV3.migrateHeader(source)
+  return { target, value: sessionFormatV2ToV3.createStage({ sourceHeader: source, targetHeader: target, sourceInheritedEventCount: sourceCut, sourceKind: 'decoded' }), collector: new SessionFormatEventCollector() }
+}
+function migrate(events: readonly SessionFormatEvent[], source = header, cut: number | undefined = 0): SessionFormatArtifact {
+  const h = stage(source, cut)
+  for (const e of dense(events)) h.value.transformEvent(e, h.collector)
+  const artifact = { header: h.target, inheritedEventCount: h.value.finish(h.collector), events: h.collector.values }
+  return restoreReleasedV3Artifact(artifact, new Set(['feedback/message-put', 'feedback/message-delete']))
+}
+const catalog = createSessionFormatCatalog({
+  currentVersion: 3,
+  codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec],
+  currentEncoder: releasedV3SessionFormatCodec,
+  migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2, sessionFormatV2ToV3],
+  restoreCurrent: artifact => restoreReleasedV3Artifact(artifact, new Set()),
+  restoreTransformedCurrent: artifact => restoreReleasedV3Artifact(artifact, new Set()),
+  restoreCurrentHeader(value) { assertReleasedV3Header(value); return value },
+})
+function requests(events: readonly SessionFormatEvent[], version: 2 | 3) {
+  const surface: SessionFormatEvent[] = []
+  let prompt = ''
+  const result: unknown[] = []
+  for (const e of events) {
+    if (e['surfaceOp'] === 'append') surface.push(e)
+    else if (e['surfaceOp'] !== undefined) {
+      const op = e['surfaceOp'] as { start: number; end: number }
+      const start = surface.findIndex(x => x.seq === op.start)
+      const end = surface.findIndex(x => x.seq === op.end)
+      surface.splice(start, end - start + 1, e)
     }
-    const run = { runType: 'opaque', firstSeq: 6, eventCount: 2, expand: vi.fn() }
-    const context = { emitEvent: vi.fn(), emitRun: vi.fn() }
-    stage.transformEvent(event, context)
-    stage.transformRun(run, context)
-    expect(context.emitEvent.mock.calls[0]?.[0]).toBe(event)
-    expect(context.emitRun.mock.calls[0]?.[0]).toBe(run)
-    expect(run.expand).not.toHaveBeenCalled()
-    expect(stage.finish(context)).toBe(0)
-  })
-
-  it('derives the inherited cut from scalar end-seed markers', () => {
-    const seeded = { ...header, isSeeded: true }
-    const stage = sessionFormatV2ToV3.createStage({
-      sourceHeader: seeded, targetHeader: { ...seeded, version: 3 },
-      sourceInheritedEventCount: undefined, sourceKind: 'transformed',
-    })
-    const context = new SessionFormatEventCollector()
-    for (const data of [null, false, [], {}, { inherited: true }]) {
-      stage.transformEvent({ type: 'session/end-seed', seq: 4, time: 1, data }, context)
+    if (e.type === 'request/header') {
+      const h = (e.data as SessionFormatJsonObject)['header'] as SessionFormatJsonObject
+      prompt = typeof h['system'] === 'string' ? h['system'] : ''
+      const messages = surface.flatMap((x) => {
+        const d = x.data as SessionFormatJsonObject
+        const m = (x.type === 'user/message' ? d : d['message']) as SessionFormatJsonObject
+        return x.type === 'system/message' && (m['content'] as unknown[]).length === 0 ? [] : [{ role: m['role'], content: m['content'] }]
+      })
+      if (version === 2 && prompt !== '') messages.unshift({ role: 'system', content: [{ type: 'text', text: prompt }] })
+      result.push(messages)
     }
-    expect(stage.finish(context)).toBe(4)
+  }
+  return result
+}
+
+describe('streaming V2 system prompt migration', () => {
+  it('emits an empty head immediately after first step, preserves chronology, and captures changed and cleared prompts', () => {
+    const input = dense([...opening(), event('user/message', user(), 'append'), event('request/header', request('first')), event('request/header', request('first')), event('request/header', request('changed')), event('request/header', request()), event('request/header', request(''))])
+    const h = stage()
+    h.value.transformEvent(input[0]!, h.collector)
+    expect(h.collector.values).toHaveLength(1)
+    h.value.transformEvent(input[1]!, h.collector)
+    expect(h.collector.values.map(e => e.type)).toEqual(['turn/start', 'step/start', 'system/message'])
+    for (const e of input.slice(2)) h.value.transformEvent(e, h.collector)
+    expect(h.value.finish(h.collector)).toBe(0)
+    const output = migrate(input)
+    expect(output.events.filter(e => e.type === 'system/message')).toHaveLength(4)
+    expect(requests(output.events, 3)).toEqual(requests(input, 2))
+    expect(output.events.filter(e => e.type !== 'system/message').map(e => [e.type, e.time])).toEqual(input.map(e => [e.type, e.time]))
+    expect(output.events.filter(e => e.type === 'request/header').every(e => !Object.hasOwn((e.data as SessionFormatJsonObject)['header'] as SessionFormatJsonObject, 'system'))).toBe(true)
+    expect(migrate(input)).toEqual(output)
+    expect(input[3]!.data).toEqual(request('first'))
   })
 
-  it('rejects missing, mismatched, and unseeded inherited cuts', () => {
-    const context = new SessionFormatEventCollector()
-    for (const [isSeeded, sourceCut, marker] of [[true, undefined, undefined], [true, 2, 1], [false, undefined, 1]] as const) {
-      const source = { ...header, isSeeded }
-      const stage = sessionFormatV2ToV3.createStage({
-        sourceHeader: source, targetHeader: { ...source, version: 3 },
-        sourceInheritedEventCount: sourceCut, sourceKind: 'decoded',
-      })
-      if (marker !== undefined) stage.transformEvent({
-        type: 'session/end-seed', seq: marker, time: 1, data: { inherited: true },
-      }, context)
-      expect(() => stage.finish(context)).toThrow(/inherited/)
+  it('remaps exact local ranges and lists without putting the head inside compaction', () => {
+    const source = dense([...opening(), event('user/message', user('a'), 'append'), event('request/header', request('sys')), event('user/message', user('b'), 'append'), event('compaction/prune', { shadowedRange: { start: 2, end: 4 }, shadowedSeqs: [2, 4], shadowedTokenCount: 20 }), { ...event('user/message', user('replacement'), { op: 'replace', start: 2, end: 4 }), sourceEventSeqs: [2, 4] }, event('command/run', { commandId: 'c', name: 'x', source: { kind: 'user' } }), event('command/done', { commandId: 'c', kind: 'success', sourceEventSeq: 6 }), event('session/title', { title: 'title', messageSeqs: [2, 4], source: { kind: 'fallback' } })])
+    const target = migrate(source)
+    const mapped = target.events.filter(e => e.type !== 'system/message')
+    const prune = mapped[5]!.data as SessionFormatJsonObject
+    expect(prune['shadowedSeqs']).toEqual([mapped[2]!.seq, mapped[4]!.seq])
+    expect(mapped[6]!['sourceEventSeqs']).toEqual(prune['shadowedSeqs'])
+    expect((mapped[8]!.data as SessionFormatJsonObject)['sourceEventSeq']).toBe(mapped[6]!.seq)
+    expect((mapped[9]!.data as SessionFormatJsonObject)['messageSeqs']).toEqual(prune['shadowedSeqs'])
+    expect(() => restoreReleasedV3Artifact({ ...target, events: target.events.map(e => e.type === 'compaction/prune' ? { ...e, data: { ...e.data as SessionFormatJsonObject, shadowedRange: { start: 4, end: 4 }, shadowedSeqs: [4] } } : e) }, new Set())).toThrow(/protected/)
+  })
+
+  it('keeps headerless aborted steps and metadata-only logs without inventing a tail request', () => {
+    const empty = migrate([])
+    expect(empty.events).toEqual([])
+    expect(migrate([...opening(), event('user/message', user(), 'append')]).events.at(-1)?.type).toBe('user/message')
+    expect(migrate([event('feedback/record', { text: 'metadata' })]).events).toHaveLength(1)
+  })
+
+  it('detects deterministic head and update identity collisions with source messages in either order', () => {
+    const input = [...opening(), event('user/message', user(), 'append'), event('request/header', request('system'))]
+    const output = migrate(input)
+    const ids = output.events.filter(e => e.type === 'system/message').map(e => ((e.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject)['id'] as string)
+    for (const id of ids) {
+      const colliding = [...opening(), event('user/message', user(id), 'append'), event('request/header', request('system'))]
+      expect(() => migrate(colliding)).toThrow(/collides/)
     }
+    const priorInbox = event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [user('placeholder')] })
+    const shifted = migrate([priorInbox, ...input])
+    const head = shifted.events.find(e => e.type === 'system/message')!
+    const id = ((head.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject)['id'] as string
+    expect(() => migrate([event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [user(id)] }), ...input])).toThrow(/collides/)
   })
 
-  it('round-trips v3 records with identical v2 provenance encoding', () => {
-    const current = { ...header, version: 3 }
-    const event = { type: 'external/event', seq: 3, time: 1, data: null, sourceEventSeqs: [0, 1, 2] }
-    const physical = releasedV3SessionFormatCodec.encodeHeader(current, 0)
-    expect(physical).toEqual({ ...releasedV2SessionFormatCodec.encodeHeader(header, 0), version: 3 })
-    expect(releasedV3SessionFormatCodec.decodeHeader(physical)).toEqual(current)
-    const row = releasedV3SessionFormatCodec.encodeEvent(event)
-    expect(row).toEqual(releasedV2SessionFormatCodec.encodeEvent(event))
-    const decoder = releasedV3SessionFormatCodec.createDecoder(physical, 'strict')
-    const context = new SessionFormatEventCollector()
-    const first = { type: 'external/event', seq: 0, time: 1, data: null }
-    decoder.decodeRow(first, context)
-    expect(decoder.header).toEqual(current)
-    expect(decoder.finish(context)).toBe(0)
-    expect(context.values).toEqual([first])
+  it('refuses pre-step surfaces and changed prompts outside a step rather than reorder source history', () => {
+    expect(() => migrate([event('user/message', user(), 'append'), ...opening()])).toThrow(/before first step/)
+    expect(() => migrate([event('turn/start', { turn: 1 }), event('request/header', request('early')), event('step/start', { turn: 1, step: 1 })])).toThrow(/outside an open step/)
+    expect(() => migrate([...opening(), event('step/end', { turn: 1, step: 1 }), event('request/header', request('late'))])).toThrow(/outside an open step/)
   })
 
-  it.each([null, [], false, { version: 2 }])('rejects a non-v3 physical header %j', (value) => {
-    expect(() => releasedV3SessionFormatCodec.decodeHeader(value)).toThrow(/format v3 physical/)
+  it.each([undefined, 0, 1, 2])('preserves delivery generation coordinates (%s) and validates ownership before promotion', (version) => {
+    const marker = event('session-log-deepseek/delivery-accepted', { sessionId: header.id, throughSeq: 1, ...(version === undefined ? {} : { sessionFormatVersion: version }) })
+    const target = migrate([...opening(), marker])
+    expect(target.events.at(-1)?.data).toEqual(marker.data)
+    expect(() => migrate([...opening(), { ...marker, data: { ...marker.data as SessionFormatJsonObject, sessionId: 'foreign', sessionFormatVersion: 2 } }])).toThrow(/wrong Session/)
+  })
+
+  it('derives unknown seeded cuts after insertion and isolates simultaneous stages', () => {
+    const source = { ...header, isSeeded: true, parentSession: 'parent' }
+    const events = dense([...opening(), event('request/header', request('seed')), event('session-log-deepseek/delivery-accepted', { sessionId: 'parent', throughSeq: 1, sessionFormatVersion: 2 }), event('session/end-seed', { inherited: true })])
+    const a = stage(source, undefined)
+    const b = stage()
+    expect(a.value.headerInheritedEventCount).toBeUndefined()
+    expect(b.value.headerInheritedEventCount).toBe(0)
+    for (const e of events) a.value.transformEvent(e, a.collector)
+    expect(a.value.finish(a.collector)).toBe(6)
+    expect(b.value.finish(b.collector)).toBe(0)
+    expect(migrate(events, source, 4).inheritedEventCount).toBe(6)
+    expect(() => migrate(events, source, 3)).toThrow(/source cut/)
+    expect(() => migrate([], source, undefined)).toThrow(/inherited/)
+    expect(() => migrate([event('session/end-seed', { inherited: true })])).toThrow(/inherited/)
+  })
+
+  it('preserves exact TOOL_NOT_STARTED message IDs through coordinate shifts', () => {
+    const callId = 'call-with-dashes'
+    const assistant = event('assistant/message', { turn: 1, step: 1, stream: [], message: { id: 'assistant', role: 'assistant', content: [{ type: 'tool-call', id: callId, name: 'test', arguments: '{}' }], source: { kind: 'model', provider: 'mock', model: 'mock' } } }, 'append')
+    const repair = event('tool/result', { turn: 1, step: 1, error: { name: 'ToolNotStartedError', code: 'TOOL_NOT_STARTED' }, message: { id: 'interrupted-tool-result-' + callId + '-4', role: 'user', source: { kind: 'tool', callId }, content: [{ type: 'tool-result', toolCallId: callId, isError: true, content: [{ type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.' }] }] } }, 'append')
+    const input = [...opening(), event('request/header', request('system')), assistant, repair, event('step/end', { turn: 1, step: 1 })]
+    const target = migrate(input)
+    expect(target.events.find(e => e.type === 'tool/result')?.data).toEqual(repair.data)
+    expect(target.events.find(e => e.type === 'tool/result')?.seq).toBe(6)
+    const data = repair.data as SessionFormatJsonObject
+    const historical = { ...repair, data: { ...data, message: { ...data['message'] as SessionFormatJsonObject, id: 'interrupted-tool-result-' + callId + '-999' } } }
+    const inherited = migrate([...input.slice(0, 4), historical, ...input.slice(5)])
+    expect(inherited.events.find(e => e.type === 'tool/result')?.data).toEqual(historical.data)
+    expect(restoreReleasedV3Artifact(inherited, new Set())).toBe(inherited)
+    expect(() => migrate([...input.slice(0, 4), { ...repair, data: { ...data, message: { ...data['message'] as SessionFormatJsonObject, id: 'arbitrary-repair-id' } } }])).toThrow(/canonical historical/)
+  })
+
+  it('preserves other-session captures, workflow-local seq, and model input containing source numbers', () => {
+    const reference = { kind: 'session-reference', form: 'recall', version: 1, references: [{ sessionId: 'other', label: 'other', capturedThroughSeq: 99, capturedFormatVersion: 2, compacted: false, originalMessages: 1, retainedMessages: 1, omittedMessages: 0, omittedBytes: 0, truncated: false, inputIndex: 0 }] }
+    const input = [...opening(), event('user/message', { ...user(), source: reference }, 'append'), event('tool-workflow/agent-start', { runId: 'run', seq: 99, label: 'child', childId: 'other' }), event('user/message', user('human'), 'append'), event('session/title-llm-request', { titleProvider: 'mock', messageSeqs: [4], route: { provider: 'mock', model: 'mock' }, system: 'title system', messages: [{ ...user('title'), source: { kind: 'plugin', plugin: 'dsh-session-title-llm' }, content: [{ type: 'text', text: 'source seq=4 (preserved model input)' }] }], maxTokens: 20 })]
+    const target = migrate(input)
+    const kept = target.events.filter(e => e.type !== 'system/message')
+    expect(kept[2]?.data).toEqual(input[2]?.data)
+    expect(kept[3]?.data).toEqual(input[3]?.data)
+    expect((kept[5]!.data as SessionFormatJsonObject)['messageSeqs']).toEqual([5])
+    expect((kept[5]!.data as SessionFormatJsonObject)['messages']).toEqual((input[5]!.data as SessionFormatJsonObject)['messages'])
+  })
+
+  it('expands compact input incrementally through independent stage state', () => {
+    const h = stage()
+    const input = dense([...opening(), event('request/header', request('run'))])
+    h.value.transformRun({ runType: 'test', firstSeq: 0, eventCount: 3, *expand() { yield* input } }, h.collector)
+    expect(h.value.finish(h.collector)).toBe(0)
+    expect(h.collector.values).toEqual(migrate(input).events)
+  })
+
+  it.each([0, 1])('derives the inherited cut after V%s assistant chunks collapse upstream', (version) => {
+    const chunks = [
+      event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hello' } }),
+      event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } } }),
+    ]
+    const assistant = { ...event('assistant/message', { turn: 1, step: 1, message: { id: 'assistant', role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' }, content: [{ type: 'text', text: 'hello' }] } }, 'append'), sourceEventSeqs: [3, 4] }
+    const source = dense([...opening(), event('request/header', request('seed')), ...chunks, assistant, event('step/end', { turn: 1, step: 1 }), event('turn/end', { turn: 1, reason: { kind: 'completed' } }), event('session/end-seed', {})])
+    const restore = catalog.createRestore({ type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0, seedLength: 8 }, { recovery: 'strict', validation: 'current' })
+    for (const e of source) restore.decodeRow(e)
+    const output = restore.finish()
+    expect(output.inheritedEventCount).toBe(8)
+    expect(output.events.filter(e => e.type === 'assistant/chunk')).toHaveLength(0)
+    expect(output.events.filter(e => e.type === 'system/message')).toHaveLength(2)
+    expect(output.events.at(-1)?.seq).toBe(output.inheritedEventCount)
+  })
+
+  it.each([0, 1, 2])('restores seeded V%s through physical codecs and every adjacent stage', (version) => {
+    const source = dense([...opening(), event('user/message', user(), 'append'), event('request/header', request('seed')), event('step/end', { turn: 1, step: 1 }), event('turn/end', { turn: 1, reason: { kind: 'completed' } }), event('session/end-seed', version === 2 ? { inherited: true } : {})])
+    const physical = version === 2 ? { type: 'session', ...header, version, isSeeded: true } : { type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0, seedLength: 6 }
+    const restore = catalog.createRestore(physical, { recovery: 'strict', validation: 'current' })
+    for (const e of source) restore.decodeRow(e)
+    const output = restore.finish()
+    expect(output.inheritedEventCount).toBe(8)
+    expect(requests(output.events, 3)).toEqual(requests(source, 2))
+  })
+
+  it.each([event('external/opaque', { seq: 1 }), { ...event('external/opaque', {}), ignorable: true }, event('request/header', { ...request(), futureRef: 0 }), event('user/message', { ...user(), source: { kind: 'future', seq: 0 } }, 'append')])('rejects unaudited payloads %j', (bad) => {
+    expect(() => migrate([...opening(), bad])).toThrow(/unclassified|unexpected/)
+  })
+
+  it('rejects invalid references, future generation claims, and unclassified message content', () => {
+    expect(() => migrate([...opening(), { ...event('user/message', user(), 'append'), sourceEventSeqs: [99] }])).toThrow(/earlier/)
+    expect(() => migrate([...opening(), event('system/message', {})])).toThrow(/unclassified/)
+    expect(() => migrate([...opening(), event('session-log-deepseek/delivery-accepted', { sessionId: header.id, throughSeq: 1, sessionFormatVersion: 3 })])).toThrow(/format v3|between/)
+    expect(() => migrate([...opening(), event('user/message', { ...user(), content: [{ type: 'future-block', seq: 1 }] }, 'append')])).toThrow(/unclassified message content/)
   })
 
-  it.each([false, true])('validates V2 delivery ownership before promotion (inherited=%s)', (inherited) => {
-    const source = { ...header, isSeeded: inherited, ...(inherited ? { parentSession: 'parent' } : {}) }
-    const stage = sessionFormatV2ToV3.createStage({
-      sourceHeader: source, targetHeader: { ...source, version: 3 },
-      sourceInheritedEventCount: undefined, sourceKind: 'decoded',
-    })
+  it('preserves message feedback identities and rejects unaudited feedback fields', () => {
+    const feedback = event('feedback/message-put', { sessionId: 'other', item: { messageId: 'unchanged', rating: 'positive', version: 'opaque', createdAt: 1, updatedAt: 2 } })
+    expect(migrate([...opening(), feedback]).events.at(-1)?.data).toEqual(feedback.data)
+    const bad = { ...feedback, data: { ...feedback.data as SessionFormatJsonObject, seq: 2 } }
+    expect(() => migrate([...opening(), bad])).toThrow(/unexpected/)
+  })
+})
+
+describe('native V3 codec and restorer', () => {
+  it('round-trips system messages and empty heads, returning no projected user-message substitutes', () => {
+    const target = migrate([...opening(), event('request/header', request('system'))])
+    const restore = catalog.createRestore(releasedV3SessionFormatCodec.encodeHeader(target.header, 0), { recovery: 'strict', validation: 'current' })
+    for (const e of target.events) restore.decodeRow(releasedV3SessionFormatCodec.encodeEvent(e))
+    const output = restore.finish()
+    expect(output).toEqual(target)
+    expect(restoreReleasedV3Artifact(target, new Set())).toBe(target)
+    expect(target.events.filter(e => e.type === 'user/message')).toHaveLength(0)
+  })
+  it('rejects retired header.system on encode, decode, and logical restore', () => {
+    const bad = { ...event('request/header', request('retired')), seq: 2 }
+    expect(() => releasedV3SessionFormatCodec.encodeEvent(bad)).toThrow(/header.system/)
+    const decoder = releasedV3SessionFormatCodec.createDecoder({ type: 'session', ...header, version: 3 }, 'strict')
     const context = new SessionFormatEventCollector()
-    stage.transformEvent({ type: 'session-log-deepseek/delivery-accepted', seq: 0, time: 1,
-      data: { sessionId: 'parent', sessionFormatVersion: 2 } }, context)
-    if (inherited) stage.transformEvent({ type: 'session/end-seed', seq: 1, time: 1, data: { inherited: true } }, context)
-    if (inherited) {
-      expect(stage.finish(context)).toBe(1)
-      stage.transformEvent({ type: 'session-log-deepseek/delivery-accepted', seq: 2, time: 1,
-        data: { sessionId: 'parent', sessionFormatVersion: 2 } }, context)
+    for (const e of dense(opening())) decoder.decodeRow(e, context)
+    expect(() => decoder.decodeRow(bad, context)).toThrow(/header.system/)
+    const artifact = { header: { ...header, version: 3 }, inheritedEventCount: 0, events: [...dense(opening()), bad] }
+    expect(() => restoreReleasedV3Artifact(artifact, new Set())).toThrow(/header.system/)
+  })
+  it('rejects malformed native system payloads and foreign payload members', () => {
+    const target = migrate(opening())
+    const system = target.events[2]!
+    const data = system.data as SessionFormatJsonObject
+    const message = data['message'] as SessionFormatJsonObject
+    const invalid = [
+      { ...system, data: { ...data, extra: 0 } },
+      { ...system, data: { ...data, message: { ...message, role: 'user' } } },
+      { ...system, data: { ...data, message: { ...message, source: { kind: 'user' } } } },
+      { ...system, data: { ...data, message: { ...message, content: [{ type: 'text', text: 12 }] } } },
+      { ...system, surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
+    ]
+    for (const bad of invalid) {
+      expect(() => restoreReleasedV3Artifact({ ...target, events: [...target.events.slice(0, 2), bad] }, new Set())).toThrow()
     }
-    expect(() => stage.finish(context)).toThrow(/wrong Session/)
+    const decoder = releasedV3SessionFormatCodec.createDecoder({ type: 'session', ...header, version: 3 }, 'recoverable')
+    const collector = new SessionFormatEventCollector()
+    for (const e of dense(opening())) decoder.decodeRow(e, collector)
+    decoder.decodeRow(null, collector)
+    expect(() => decoder.decodeRow({ ...event('request/header', request('retired')), seq: 2 }, collector)).toThrow(/header.system/)
   })
 
-  it.each([undefined, 0, 1, 2, 4])('preserves delivery marker generation %s verbatim', (version) => {
-    const stage = sessionFormatV2ToV3.createStage({
-      sourceHeader: header, targetHeader: { ...header, version: 3 },
-      sourceInheritedEventCount: 0, sourceKind: 'decoded',
-    })
-    const event = { type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
-      data: { sessionId: header.id, throughSeq: 0, ...(version === undefined ? {} : { sessionFormatVersion: version }) } }
-    const context = new SessionFormatEventCollector()
-    stage.transformEvent(event, context)
-    expect(stage.finish(context)).toBe(0)
-    expect(context.values).toEqual([event])
-  })
-
-  it('checks native v3 delivery ownership without reinterpreting historical markers', () => {
-    const artifact = (version: number) => ({
-      header: { ...header, version: 3 }, inheritedEventCount: 0, events: [{
-        type: 'session-log-deepseek/delivery-accepted', seq: 0, time: 1,
-        data: { sessionId: 'other-session', throughSeq: 0, sessionFormatVersion: version },
-      }],
-    })
-    expect(() => restoreReleasedV3Artifact(artifact(3), new Set())).toThrow(/wrong Session/)
-    expect(restoreReleasedV3Artifact(artifact(2), new Set()).events).toEqual(artifact(2).events)
-  })
-
-  it('validates v3 metadata and event admission without mutating the artifact', () => {
-    expect(() => { assertReleasedV3Header(header) }).toThrow(/format v3 header/)
-    expect(() => { assertReleasedV3Header({ ...header, version: 3, cwd: 'relative' }) }).toThrow(/absolute/)
-    const artifact = { header: { ...header, version: 3 }, inheritedEventCount: 0, events: [
-      { type: 'external/event', seq: 0, time: 1, data: null, ignorable: true },
-    ] }
+  it('rejects system nodes outside the open step and mixed head replacements', () => {
+    const target = migrate([...opening(), event('user/message', user(), 'append'), event('request/header', request('system'))])
+    const systems = target.events.filter(e => e.type === 'system/message')
+    const wrongStep = target.events.map(e => e === systems[0] ? { ...e, data: { ...e.data as SessionFormatJsonObject, step: 2 } } : e)
+    expect(() => restoreReleasedV3Artifact({ ...target, events: wrongStep }, new Set())).toThrow(/open step/)
+    expect(() => restoreReleasedV3Artifact({ ...target, events: target.events.map(e => e === systems[1] ? { ...e, surfaceOp: { op: 'replace', start: 2, end: 3 }, sourceEventSeqs: [2, 3] } : e) }, new Set())).toThrow(/exactly/)
+  })
+  it('round-trips in-history system append, replacement, and compaction without shadowing the head', () => {
+    const base = migrate([...opening(), event('user/message', user(), 'append')])
+    const system = base.events.find(e => e.type === 'system/message')!
+    const message = (system.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject
+    const append = { ...system, seq: 4, data: { ...system.data as SessionFormatJsonObject, message: { ...message, id: 'tail', source: { kind: 'plugin', plugin: 'context-plugin' }, content: [{ type: 'text', text: 'tail context' }, { type: 'reasoning', text: 'retained content' }] } } }
+    const replace = { ...system, seq: 5, sourceEventSeqs: [4], surfaceOp: { op: 'replace', start: 4, end: 4 } }
+    const prune = { ...event('compaction/prune', { shadowedRange: { start: 5, end: 5 }, shadowedSeqs: [5], shadowedTokenCount: 0 }), seq: 6 }
+    const checkpoint = { ...event('user/message', user('checkpoint'), { op: 'replace', start: 5, end: 5 }), sourceEventSeqs: [5], seq: 7 }
+    const artifact = { ...base, events: [...base.events, append, replace, prune, checkpoint] }
     expect(restoreReleasedV3Artifact(artifact, new Set())).toBe(artifact)
-    expect(artifact.header.version).toBe(3)
-    expect(() => restoreReleasedV3Artifact({ ...artifact, events: [
-      { type: 'external/required', seq: 0, time: 1, data: null },
-    ] }, new Set())).toThrow(/unknown event type/)
+    const restore = catalog.createRestore(releasedV3SessionFormatCodec.encodeHeader(base.header, 0), { recovery: 'strict', validation: 'current' })
+    for (const e of artifact.events) restore.decodeRow(releasedV3SessionFormatCodec.encodeEvent(e))
+    expect(restore.finish()).toEqual(artifact)
+    const protectedPrune = { ...prune, data: { shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2], shadowedTokenCount: 0 } }
+    const invalid = { ...base, events: [...base.events, append, replace, protectedPrune] }
+    expect(() => restoreReleasedV3Artifact(invalid, new Set())).toThrow(/protected/)
+  })
+
+  it('keeps native ordinary payload and message-source extensions distinct from structural migration admission', () => {
+    const target = migrate([...opening(), event('user/message', user(), 'append')])
+    const events = target.events.map(e => e.type === 'user/message' ? { ...e, data: { ...e.data as SessionFormatJsonObject, installedExtension: true, source: { kind: 'installed-source', revision: 1 } } } : e)
+    const native = { ...target, events }
+    expect(restoreReleasedV3Artifact(native, new Set())).toBe(native)
+    expect(() => migrate([...opening(), event('user/message', { ...user(), installedExtension: true }, 'append')])).toThrow(/unexpected/)
+  })
+
+  it('retains equal-generation ignorable events but rejects unknown required events', () => {
+    const artifact = { header: { ...header, version: 3 }, inheritedEventCount: 0, events: [{ ...event('external/event', null), ignorable: true }] }
+    expect(restoreReleasedV3Artifact(artifact, new Set())).toBe(artifact)
+    expect(() => restoreReleasedV3Artifact({ ...artifact, events: [event('external/event', null)] }, new Set())).toThrow(/unknown event/)
+  })
+  it.each([null, [], false, { version: 2 }])('rejects non-v3 physical metadata %j', (value) => {
+    expect(() => releasedV3SessionFormatCodec.decodeHeader(value)).toThrow(/format v3 physical/)
   })
 })

+ 3 - 0
packages/session/session-format-v2-to-v3/tsconfig.json

@@ -14,6 +14,9 @@
     {
       "path": "../session-format"
     },
+    {
+      "path": "../session-format-v0-to-v1"
+    },
     {
       "path": "../session-format-v1-to-v2"
     }