diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
index 29e9ee11ca..b32db4586d 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.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-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: a5d343d3946199c715d34389e27798c57256af21
-2026-08-31-released-session-format-migrations.zh.md: 7213857ad9145cd1faf6c244c442e2656b3f273d
+2026-08-31-released-session-format-migrations.md: 9f749fe9acc3dc4ca530034791c71513a5e5d2d1
+2026-08-31-released-session-format-migrations.zh.md: 5818419c589d67f8169a06d54ee1b07b5443bfd6
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
index a5d343d394..9f749fe9ac 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
@@ -66,6 +66,8 @@ The chain contains no `flatMap`, spread expansion, intermediate event array, or
### Adjacent version ownership
+V2→V3 refuses source delivery markers claiming V3 acceptance: a marker ignored in V2 must not become an active V3 upload watermark merely because the header changes. Other non-current marker generations retain their ignored meaning. Python release smoke checks generated logs against the source `SESSION_FORMAT_VERSION` independently of generation-neutral golden comparison, so coherent filenames and headers cannot conceal an outdated writer.
+
V2→V3 provides an identity body stage and a distinct V3 codec, header validator, and restorer. The released V2 codec remains owned by V1→V2 and is reused, not copied. Identity preserves accepted logical events and the inherited cut; the header version and successor filename change. Its admission retains installed ordinary event additions and unknown ignorable events, while unknown required events refuse. Unchanged sequence numbers, references, payloads, and ordering make that preservation safe for the identity edge; structural extensions must reassess it rather than inherit an unconditional opaque-event promise. The [format-version cookbook](../../../../docs/cookbook/adding-a-session-format-version.md) owns package wiring, current consumers, snapshot successors, and validation commands.
A source inherited count can be unknown before EOF: V2 derives it from seed markers, and V1→V2 can change cardinality. The chain passes that absence to the next stage instead of fabricating a count. V2→V3 validates and derives its cut from markers; older stages that require a header-supplied count still refuse when it is absent. This permits seeded multi-hop restoration without retaining an intermediate artifact array.
diff --git a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
index 7213857ad9..5818419c58 100644
--- a/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
+++ b/.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
@@ -66,6 +66,8 @@ Chain 中不存在 `flatMap`、spread expansion、中间 event array 或 schedul
### 相邻版本所有权
+V2→V3 拒绝声称 V3 已接受投递的源标记:V2 中被忽略的标记不能仅因 header 变化就成为有效的 V3 上传水位。其他非当前代际标记仍保持被忽略的含义。Python 发布冒烟测试独立于跨代 golden 比较,按源代码中的 `SESSION_FORMAT_VERSION` 检查生成日志,因此文件名与 header 自洽不能掩盖过期 writer。
+
V2→V3 提供恒等正文 Stage,以及独立的 V3 codec、header 校验器与恢复器。已发布 V2 codec 仍归 V1→V2 所有,并被复用而非复制。恒等保留可接受的逻辑事件和继承截点;header 版本与后继文件名会变化。其准入保留已安装的普通事件新增项和未知可忽略事件,同时拒绝未知必需事件。序号、引用、payload 与顺序不变,使恒等迁移边可以安全保留这些数据;结构性扩展必须重新评估,而非继承无条件保留不透明事件的承诺。[格式版本实操手册](../../../../docs/cookbook/adding-a-session-format-version.zh.md)负责包接线、当前消费方、快照后继代际与验证命令。
源继承数量在 EOF 前可能未知:V2 从种子标记推导它,而 V1→V2 可以改变事件数量。迁移链将这种缺失传递给下一个 Stage,而不伪造数量。V2→V3 校验并从标记推导截点;需要 header 提供数量的旧 Stage 仍在数量缺失时拒绝。这使有种子的多跳恢复无需保留中间产物数组。
diff --git a/packages/session/session-format-catalog/tests/catalog.spec.ts b/packages/session/session-format-catalog/tests/catalog.spec.ts
index 2d6cae3e5e..1cb0a8f0c2 100644
--- a/packages/session/session-format-catalog/tests/catalog.spec.ts
+++ b/packages/session/session-format-catalog/tests/catalog.spec.ts
@@ -206,6 +206,17 @@ describe('first-party Session format catalog', () => {
])
})
+ it.each(['current', 'transformed'] as const)('refuses a v3 delivery marker in v2 input (%s)', (validation) => {
+ const header = { type: 'session', version: 2, id: 'future-delivery', createdAt: 1, isSeeded: false, delegationDepth: 0 }
+ const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
+ restore.decodeRow({ type: 'feedback/record', seq: 0, time: 1, data: { text: 'unaccepted' } })
+ expect(() => {
+ restore.decodeRow({ type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
+ data: { sessionId: header.id, throughSeq: 0, sessionFormatVersion: 3 } })
+ restore.finish()
+ }).toThrow(/format v2 delivery marker claims target format v3/)
+ })
+
it('validates complete relationships after streaming migration', () => {
const stream = sessionFormatCatalog.createRestore({
type: 'session', version: 1, id: 'invalid-stream', createdAt: 1, delegationDepth: 0,
diff --git a/packages/session/session-format-v2-to-v3/README.i18n.yaml b/packages/session/session-format-v2-to-v3/README.i18n.yaml
index d5d4e91c37..162c99f75c 100644
--- a/packages/session/session-format-v2-to-v3/README.i18n.yaml
+++ b/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: c8248e80d42911bbb29fd92f0a8e95839a777b10
-README.zh.md: ee35b218e5ae0a846354e206354afc57a10cc2b0
+README.md: 8af117b18d2b6ad9ec3f791b4dfaa53408706cbb
+README.zh.md: 963b91e47542a8f30cb5d3a4df397ccd608cf8aa
diff --git a/packages/session/session-format-v2-to-v3/README.md b/packages/session/session-format-v2-to-v3/README.md
index c8248e80d4..8af117b18d 100644
--- a/packages/session/session-format-v2-to-v3/README.md
+++ b/packages/session/session-format-v2-to-v3/README.md
@@ -39,6 +39,8 @@ The header version becomes 3; all other header fields remain unchanged. The stag
V3 validation accepts current PTC tags, not required legacy aliases. Unknown events marked `ignorable` retain their admission policy, except that source-v2 `tool/ptc-dispatch` and `tool/ptc-dispatch-start` events are rejected even when ignorable: these names are reserved in v3, so migration cannot reinterpret an opaque extension as a PTC lifecycle event. Physical record encoding remains the released-v2 encoding; only the header version and the named logical fields change.
+The stage refuses source delivery markers whose `sessionFormatVersion` is 3 because promotion would activate an unconfirmed target-generation watermark.
+
-----
diff --git a/packages/session/session-format-v2-to-v3/README.zh.md b/packages/session/session-format-v2-to-v3/README.zh.md
index ee35b218e5..963b91e475 100644
--- a/packages/session/session-format-v2-to-v3/README.zh.md
+++ b/packages/session/session-format-v2-to-v3/README.zh.md
@@ -39,6 +39,8 @@ const targetHeader = sessionFormatV2ToV3.migrateHeader(sourceHeader)
V3 校验接受当前 PTC 标签,不接受必需的旧别名。标记为 `ignorable` 的未知事件保留其准入规则,但源 v2 中的 `tool/ptc-dispatch` 和 `tool/ptc-dispatch-start` 事件即使可忽略也会被拒绝:这些名称在 v3 中保留,迁移不能将不透明扩展重新解释为 PTC 生命周期事件。物理记录编码仍为已发布的 v2 编码;仅头部版本和上述逻辑字段发生变化。
+阶段拒绝 `sessionFormatVersion` 为 3 的源投递标记,因为升级会激活未经确认的目标代际水位。
+
-----
diff --git a/packages/session/session-format-v2-to-v3/src/migration.ts b/packages/session/session-format-v2-to-v3/src/migration.ts
index 7bb383e714..e6ebc037f4 100644
--- a/packages/session/session-format-v2-to-v3/src/migration.ts
+++ b/packages/session/session-format-v2-to-v3/src/migration.ts
@@ -12,7 +12,10 @@ import type {
import { assertReleasedV2Header } from '@deepseek-ai/dsh-session-format-v1-to-v2'
import { assertReleasedV3Header } from './validation.ts'
-/** Rename released-v2 PTC dispatch events and owned plugin attribution without changing ids or content. */
+/**
+ * Rename released-v2 PTC dispatch events and owned plugin attribution without changing ids or content.
+ * Refuse source delivery markers claiming v3 acceptance.
+ */
export const sessionFormatV2ToV3 = defineSessionFormatMigration({
name: '@deepseek-ai/dsh-session-format-v2-to-v3',
fromVersion: 2,
@@ -42,10 +45,14 @@ class ReleasedV2ToV3Stage implements SessionFormatMigrationStage {
this.inheritedEventCount = event.seq
}
if (event.type === 'session-log-deepseek/delivery-accepted'
- && isSessionFormatJsonObject(event.data)
- && event.data['sessionFormatVersion'] === 2
- && event.data['sessionId'] !== this.input.sourceHeader.id) {
- this.lastForeignDeliverySeq = event.seq
+ && 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
+ }
}
context.emitEvent(renamePtcEvent(event))
}
diff --git a/packages/session/session-format-v2-to-v3/tests/migration.spec.ts b/packages/session/session-format-v2-to-v3/tests/migration.spec.ts
index fe2104d0e4..96b9d675fd 100644
--- a/packages/session/session-format-v2-to-v3/tests/migration.spec.ts
+++ b/packages/session/session-format-v2-to-v3/tests/migration.spec.ts
@@ -270,6 +270,19 @@ describe('v2 to v3 PTC migration', () => {
expect(() => stage.finish(context)).toThrow(/wrong Session/)
})
+ 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: [{
diff --git a/python/sdk/tests/test_smoke_model.py b/python/sdk/tests/test_smoke_model.py
index bc554ec2ce..f3e0bbfe26 100644
--- a/python/sdk/tests/test_smoke_model.py
+++ b/python/sdk/tests/test_smoke_model.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import json
import runpy
from pathlib import Path
@@ -296,6 +297,33 @@ def test_snapshot_generation_comparison_rejects_changed_payload(tmp_path: Path)
)
+@pytest.mark.parametrize("version", [2, 4])
+@pytest.mark.parametrize("update", [False, True])
+def test_snapshot_comparison_rejects_noncurrent_writer(
+ tmp_path: Path, version: int, update: bool,
+) -> None:
+ golden = '{"type":"session","version":2}\n'
+ (tmp_path / "session.v2.jsonl").write_text(golden, encoding="utf-8")
+ content = json.dumps({"type": "session", "version": version}) + "\n"
+ with pytest.raises(AssertionError, match="expected current Session format v3"):
+ SMOKE["compare_snapshot_files"](
+ {f"session.v{version}.jsonl": content}, update, tmp_path, ("session.v2.jsonl",),
+ )
+ assert (tmp_path / "session.v2.jsonl").read_text(encoding="utf-8") == golden
+ assert not (tmp_path / "session.v4.jsonl").exists()
+
+
+@pytest.mark.parametrize("version", [2, 3, 4])
+def test_persisted_session_requires_current_writer(version: int) -> None:
+ content = json.dumps({"type": "session", "version": version}) + "\n"
+ path = Path(f"session.v{version}.jsonl")
+ if version == 3:
+ assert SMOKE["assert_persisted_session_version"](path, content) == version
+ else:
+ with pytest.raises(AssertionError, match="expected current Session format v3"):
+ SMOKE["assert_persisted_session_version"](path, content)
+
+
def test_snapshot_generation_filename_must_match_header(tmp_path: Path) -> None:
(tmp_path / "session.v1.jsonl").write_text(
'{"type":"session","version":0}\n', encoding="utf-8",
diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py
index 387b4555e8..58b475be2d 100644
--- a/scripts/smoke-python-runtime.py
+++ b/scripts/smoke-python-runtime.py
@@ -1549,8 +1549,25 @@ def session_header_version(content: str, label: str) -> int:
return version
+def assert_current_session_version(version: int, label: str) -> None:
+ """Require generated logs to use the source writer generation, independent of goldens."""
+ source = Path(__file__).resolve().parents[1] / "packages/core/session/src/types.ts"
+ declarations = re.findall(
+ r"^export const SESSION_FORMAT_VERSION = ([0-9]+)$",
+ source.read_text(encoding="utf-8"),
+ re.MULTILINE,
+ )
+ if len(declarations) != 1:
+ raise AssertionError(f"{source}: expected one literal SESSION_FORMAT_VERSION declaration")
+ current_version = int(declarations[0])
+ if version != current_version:
+ raise AssertionError(
+ f"{label}: expected current Session format v{current_version}, got v{version}",
+ )
+
+
def assert_persisted_session_version(path: Path, content: str) -> int:
- """Require a raw persistence basename and header to name the same generation."""
+ """Require generated persistence filenames and headers to use the current generation."""
filename_version = persisted_session_filename_version(path)
if filename_version is None:
raise AssertionError(f"non-canonical Session persistence filename: {path.name}")
@@ -1560,6 +1577,7 @@ def assert_persisted_session_version(path: Path, content: str) -> int:
f"{path.name}: filename declares Session format v{filename_version}, "
f"header declares v{header_version}",
)
+ assert_current_session_version(header_version, path.name)
return header_version
@@ -2168,6 +2186,9 @@ def compare_snapshot_files(
if tuple(map(role_name, files)) != tuple(map(role_name, filenames)):
raise AssertionError(f"{scenario} snapshot builder produced {tuple(files)}, expected {filenames}")
+ for name, content in files.items():
+ if parse_snapshot_session_filename(name) is not None:
+ assert_current_session_version(session_header_version(content, name), name)
if update:
directory.mkdir(parents=True, exist_ok=True)
for name, content in files.items():