Kaynağa Gözat

fix(session): harden and automate persistence change review

Tianyi Cui 6 gün önce
ebeveyn
işleme
a0a7e2c11c

+ 2 - 2
.agents/notes/implemented/process/2026-09-11-persistence-type-history.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/process/2026-09-11-persistence-type-history.md
-2026-09-11-persistence-type-history.md: 503dfac5a62f7ddd811877e9d07884dacd30daa8
-2026-09-11-persistence-type-history.zh.md: d2cf21e18b317d2fbe15e95a0b185e5be2713d46
+2026-09-11-persistence-type-history.md: ef2cd0d8c7013883657d6dad9eb1c4894d9172e7
+2026-09-11-persistence-type-history.zh.md: 6045176ecbc41ef758e2dd98ddb3de6eb3c049f8

+ 5 - 1
.agents/notes/implemented/process/2026-09-11-persistence-type-history.md

@@ -10,7 +10,7 @@ A persisted event can retain the same payload type expression while a referenced
 
 ## Decision
 
-One normalized type model supplies the readable persistence catalog, the complete schema inventory, and transitive per-root digests. Roots identify the Session header, the event envelope, and every repository-declared event. Source locations, alias names, comments, and property order are presentation details outside the digest. Referenced and recursive types participate in structural comparison; opaque values retain explicit coverage limits.
+One normalized type model supplies the readable persistence catalog, the complete schema inventory, and transitive per-root digests. Roots identify the logical Session header, the physical JSONL header line, the event envelope, and every repository-declared event. Source locations, alias names, comments, and property order are presentation details outside the digest. Referenced and recursive types participate in structural comparison; opaque values retain explicit coverage limits.
 
 The dedicated [persistence-change records](../../../../docs/persistence-changes/README.md) bind acknowledgement to the after digest of each affected root. A generated companion stores complete after schemas. The initial record covers all roots; later records name each root's predecessor. A predecessor's after schema supplies the next before schema. Verification rejects ambiguous history and requires current source to match the terminal recorded state without consulting Git history or remote services.
 
@@ -18,6 +18,10 @@ Automatic classification permits optional body additions, required-to-optional b
 
 The new document kind preserves the compatibility reasoning and evidence for a historical type transition. Agent Notes retain mechanism-level decisions; they do not become a growing inventory of individual acknowledgements. Machine declarations remain identical across the bilingual pair and are parsed once.
 
+Record commands generate the bilingual catalog and consistency records from repository-owned templates. Authors can supply the two languages' summary, compatibility reasoning, and actual verification evidence as structured input. Generation supplies identifiers, digests, and snapshots; it never invents a compatibility explanation or test result. Structured check output retains a nonzero failure exit status and reports stable change kinds independently of human-readable descriptions.
+
+Explicit update refreshes an unaccepted terminal record without deleting its authored prose. It recomputes the transition against the remaining history and rejects the baseline or any record with dependants. Review acceptance is not a fact available from the tree, so authors preserve accepted records and add successors.
+
 ## Alternatives considered
 
 **Hash the displayed declarations.** Referenced definitions can change without altering the displayed expression. Normalized transitive types make those changes visible while omitting source-file and alias churn.

+ 5 - 1
.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 ## 决策
 
-一个规范化类型模型提供可读持久化目录、完整 schema 清单和包含传递引用的逐根摘要。根标识会话头、事件封装以及每个仓库内声明的事件。源码位置、别名名称、注释和属性顺序属于摘要之外的展示细节。被引用类型和递归类型参与结构比较;不透明值保留明确的覆盖限制。
+一个规范化类型模型提供可读持久化目录、完整 schema 清单和包含传递引用的逐根摘要。根标识逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。源码位置、别名名称、注释和属性顺序属于摘要之外的展示细节。被引用类型和递归类型参与结构比较;不透明值保留明确的覆盖限制。
 
 专用的[持久化变更记录](../../../../docs/persistence-changes/README.zh.md)将确认绑定到每个受影响根的变更后摘要。生成的伴随文件保存完整的变更后 schema。初始记录覆盖所有根;后续记录为每个根指定前驱。前驱的变更后 schema 提供下一次的变更前 schema。验证拒绝有歧义的历史,并要求当前源码匹配最终记录状态,无需查询 Git 历史或远端服务。
 
@@ -18,6 +18,10 @@ Status: implemented
 
 新文档类型保留历史类型转换的兼容性说明和证据。Agent Note 保留机制层面的决策,不积累单次确认清单。双语对中的机器声明保持相同,且只解析一次。
 
+记录命令从仓库内模板生成双语目录和一致性记录。作者可以将两种语言的概述、兼容性说明和实际验证证据作为结构化输入。生成过程提供标识符、摘要和快照,不编造兼容性说明或测试结果。结构化检查输出保留非零失败退出码,并报告独立于可读描述的稳定变更种类。
+
+显式更新可刷新尚未接受的末端记录,无需删除其人工说明。它根据剩余历史重新计算转换,并拒绝基线或已有依赖方的记录。审阅接受状态不是目录内可得的事实,因此作者保留已接受记录并添加后继。
+
 ## 考虑过的替代方案
 
 **对展示的声明计算哈希。** 被引用的定义可能改变,而展示的表达式保持不变。规范化传递类型使这些变更可见,同时排除源文件和别名调整。

+ 3 - 2
.agents/skills/dsh-doc/templates/persistence-change.md

@@ -13,7 +13,7 @@ kind: persistence-change
 
 ## Skeleton
 
-The generator fills the machine declaration and schema companion. The following values illustrate the fields; use the actual generated root names, record ids, and digests.
+The generator fills the machine declaration, schema companion, and consistency records. Supply `--prose` JSON with `en` and `zh` objects, each containing authored `summary`, `compatibility`, and `verification` strings; the [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) shows the complete operation. The following values illustrate the document fields; use the actual generated root names, record ids, and digests.
 
 ````markdown
 # Persistence change: <specific persisted change>
@@ -67,5 +67,6 @@ None.
 - The filename stem and `id` agree. A root's `previous` identifies its predecessor record; `null` introduces a root. Its `after` matches the generated companion schema digest; `null` records deletion.
 - One initial `baseline: true` record covers the complete inventory. Routine changes use `baseline: false` and preserve accepted predecessors. A version-bump record carries its own increasing `SessionHeader.version` transition.
 - Explain only the detected persistence-type changes. This kind permits the before/after facts and historical evidence needed for that transition; behavior-only changes remain out of scope.
-- Retain the generated JSON companion and re-record the bilingual consistency sidecar after reviewing both languages. Do not hand-maintain schema nodes or digests.
+- Record creation and terminal-record update generate the catalog pair and consistency sidecars. Review both languages and retain the generated companions. Direct prose edits still require re-recording pairing; do not hand-maintain schema nodes or digests.
+- Use explicit `--update` only for an unaccepted terminal record. It rejects baselines and depended-on records; `--prose` replaces its authored explanations, while omission preserves them. Accepted records receive successors.
 - The [folder reference](../../../../docs/persistence-changes/README.md) owns compatibility rules; the [cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) owns commands and recovery.

+ 1 - 1
docs/AGENTS.md

@@ -23,7 +23,7 @@ Each fact has one home: the tier whose job it is; elsewhere, link there.
 | [architecture.md](architecture.md) | Ordered map: composition, core packages, loop, seams, extension points; read before changing `packages/` | Type definitions (→ subsystems), per-package detail (→ package READMEs), decision rationale (→ Agent Notes), implementation-status annotations |
 | [subsystems/](subsystems/README.md) | One reference page per subsystem: type definitions, semantics, and the generated Cordis API | Behavior narration (→ architecture.md) |
 | [Agent Notes](../.agents/notes/README.md) | Active decision records: the why, what-was-given-up, and required verification; `implemented/` notes describe shipped reality in present tense | Migration plans, acceptance-task checklists, fixture walkthroughs, and spec-speak ("should…") once the decision has shipped; archived notes are frozen history, never current authority |
-| [postmortem/](postmortem/README.md) | Incident stories | — |
+| [postmortem/](postmortem/README.md) | Incident stories — the only tier where war-story narrative belongs | — |
 | [persistence-changes/](persistence-changes/README.md) | Historical acknowledgements of mechanically detected persistence-type changes, with generated schema companions | Behavior-only changes; current runtime contracts |
 | [cookbook/](cookbook/adding-a-package.md) | Step-by-step how-tos with numbered verify steps | Design rationale (→ the Agent Note each guide links) |
 | [user/](user/index.md) | Product-facing guides published by the documentation website | Generated reference tables, contributor procedures, decision history |

+ 2 - 2
docs/cookbook/reviewing-persistence-type-changes.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/cookbook/reviewing-persistence-type-changes.md
-reviewing-persistence-type-changes.md: f3ac7a5c9548fa88ec2de2236e0c23f9e5472712
-reviewing-persistence-type-changes.zh.md: 2c1f4010156340f4604b3c6edd17d86ddf5a145a
+reviewing-persistence-type-changes.md: d99f48cd178ac050ff75f7ff314df8425b61f6cc
+reviewing-persistence-type-changes.zh.md: 4b809b0a772d9056e2dd0834a11b7c5127fb10a7

+ 47 - 26
docs/cookbook/reviewing-persistence-type-changes.md

@@ -8,65 +8,86 @@ English | [中文](reviewing-persistence-type-changes.zh.md)
 
 ## Summary
 
-Use this tutorial after changing a declared Session persistence type in a contributor checkout with dependencies installed. You will inspect the structural differences, record their compatibility decision, and run the same check locally that CI runs. The [record reference](../persistence-changes/README.md) explains the files and automatic rules. All comparison inputs live in the checkout; no base branch or network access is required.
+Use this tutorial after changing a declared Session persistence type in a contributor checkout with dependencies installed. You will inspect the structural differences, supply a bilingual compatibility explanation, and run the same check locally that CI runs. The [record reference](../persistence-changes/README.md) explains the files and automatic rules. All comparison inputs live in the checkout; no base branch or network access is required.
 
 ## Table of Contents
 
-- [1. Generate the current inventory](#generate)
-- [2. Create the acknowledgement](#acknowledge)
-- [3. Explain and verify the change](#verify)
-- [4. Resolve competing records](#competing-records)
+- [1. Inspect the change](#generate)
+- [2. Supply the explanation and create a record](#acknowledge)
+- [3. Verify the result](#verify)
+- [4. Update an unaccepted record](#competing-records)
 - [Dev Note](#dev-note)
 
 -----
 
 <a id="generate"></a>
-## 1. Generate the current inventory
+## 1. Inspect the change
 
 Run from the repository root after editing the type and its consumers:
 
 ```sh
-pnpm run gen-persistence-catalog
-pnpm run verify-persistence-changes
+pnpm --silent run verify-persistence-changes --json
 ```
 
-Review the generated [catalog](../persistence-catalog.md) and [schema inventory](../persistence-schema.json). A nested referenced type can affect several event digests; inspect all reported roots. Regeneration does not acknowledge a change. Until the history covers the new schemas, verification reports the changed paths and fails.
+Read the reported root, path, change kind, and version requirement. A referenced type can affect several event digests; inspect every affected root. Until the history covers the new schemas, verification fails. A stale generated inventory also fails verification; the recording command refreshes it. If only presentation details changed and `changes` is empty, run `pnpm run gen-persistence-catalog`; no new acknowledgement is needed.
 
 <a id="acknowledge"></a>
-## 2. Create the acknowledgement
+## 2. Supply the explanation and create a record
+
+Choose the decision from the [compatibility rules](../persistence-changes/README.md#compatibility-rules). Write a local JSON file containing `en` and `zh`, each with `summary`, `compatibility`, and `verification` strings. The following input describes an exercised required-to-optional hook audit field change. Replace the explanation and test evidence with facts about your change; the CLI does not establish these claims.
+
+Save the input as `.artifacts/persistence-change.prose.json`, creating the ignored directory if needed:
+
+```json
+{
+  "en": {
+    "summary": "Makes the persisted hook audit decision optional.",
+    "compatibility": "Existing records remain valid. Hook execution consumes HookOutput instead of replaying this audit field. Producers still write decisions, and absence does not imply pass.",
+    "verification": "pnpm exec vitest run packages/hooks/hook-protocol/tests/events.spec.ts: 10 tests passed."
+  },
+  "zh": {
+    "summary": "将持久化的钩子审计决策改为可选。",
+    "compatibility": "已有记录仍然有效。钩子执行消费 HookOutput,不回放此审计字段。写入方仍然记录决策,缺失不代表 pass。",
+    "verification": "pnpm exec vitest run packages/hooks/hook-protocol/tests/events.spec.ts:10 个测试通过。"
+  }
+}
+```
 
-Choose the decision from the [compatibility rules](../persistence-changes/README.md#compatibility-rules). For an optional body addition, use a date and descriptive slug in place of the example id:
+Use a date and descriptive slug in place of this example id:
 
 ```sh
-pnpm run persistence-changes --record 2026-09-11-optional-display-metadata --decision same-version
+pnpm --silent run persistence-changes --record 2026-09-11-poc-optional --decision same-version --prose .artifacts/persistence-change.prose.json --json
 ```
 
-The command creates English and Chinese record drafts and a generated schema companion in [persistence-changes](../persistence-changes/README.md). Each affected root references its latest recorded predecessor. The after schemas and digests come from source; do not edit them by hand.
+The command creates the record pair and complete after schemas, updates both generated catalogs and the machine inventory, and records bilingual pairing. Review the authored explanations and generated diff before committing the files reported in `files`. The prose input is an authoring file; the resulting documents retain the explanation. Omitting `--prose` creates unfinished drafts that verification rejects until their explanations are completed.
 
-For a change requiring a bump, first follow [adding a Session format version](adding-a-session-format-version.md), then use `--decision version-bump`. The record must include its own increasing `SessionHeader.version` transition. An unrelated historical bump cannot authorize this change. Do not create another baseline for routine changes.
+For a change requiring a bump, first follow [adding a Session format version](adding-a-session-format-version.md), then use `--decision version-bump`. The record must include its own increasing `SessionHeader.version` transition. An unrelated historical bump cannot authorize this change. Routine changes never create another baseline.
 
 <a id="verify"></a>
-## 3. Explain and verify the change
-
-Replace the generated compatibility and verification placeholders in both languages. Keep the machine declarations identical. Explain only the reported type changes, and record actual focused test results. For optional additions, review whether old records can omit the data and older readers can ignore it without changing replay; automatic classification cannot prove that explanation.
+## 3. Verify the result
 
-Update the catalog's Chinese counterpart with the generated English changes. Confirm both pairs, then verify:
+The same check runs locally and in CI:
 
 ```sh
-pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-11-optional-display-metadata.md
-pnpm run verify-persistence-catalog
-pnpm run verify-persistence-changes
+pnpm --silent run verify-persistence-changes --json
 ```
 
-Use your actual record id in the pairing command. Verification succeeds when generated files match source, every transition satisfies its classification, and every current root matches its terminal history state. Include the generated files, record pair, and both consistency sidecars in the PR. Run the other checks selected by the changed code; this check does not replace behavior tests or migration validation.
+Success reports `ok: true` when the generated inventory matches source, each transition satisfies its classification, and current roots match their terminal history states. Failure reports `ok: false` and exits with code 1; do not treat parseable JSON as success. The response includes `operation`, `message`, structured `changes`, per-root before/after digests in `roots`, generated `files`, and a failure `code` when applicable. Each change has a stable `kind`; automation does not need to parse the description.
+
+Run the other checks selected by the changed code, including documentation checks for accompanying authored pages. Record generation owns its catalog and record pairs; edits to a package README or other bilingual page still follow their normal pairing workflow. Persistence-type checks do not replace behavior tests or migration validation.
 
 <a id="competing-records"></a>
-## 4. Resolve competing records
+## 4. Update an unaccepted record
+
+If source changes after recording, review the compatibility explanation and refresh the same unaccepted terminal record:
+
+```sh
+pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --prose .artifacts/persistence-change.prose.json --json
+```
 
-When integration reports two successors to one root's predecessor, keep the accepted record and recreate the unaccepted record against it using a new id. Reassess the resulting combined type change and rerun the checks. An unrelated root's record does not need refreshing.
+The command refreshes the machine declaration, schemas, catalogs, and pairing. Without `--prose`, it preserves the existing explanation. Update refuses the initial baseline and records that another record depends on. The tree cannot identify which records were accepted in review: preserve accepted history and create a successor instead.
 
-If source changes after a draft was generated, discard only that unaccepted draft and its companions, regenerate the inventory, and create the replacement record. Preserve accepted history. A later type edit must not retain an acknowledgement of a different digest. The [mechanism decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) explains why complete snapshots and per-root predecessors are retained.
+When integration creates competing terminal records, update the unaccepted record against the remaining history, then reassess the resulting diff. An unrelated root's acknowledgement does not need refreshing. The [mechanism decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) explains why complete snapshots and per-root predecessors are retained.
 
 <a id="dev-note"></a>
 ## Dev Note

+ 47 - 26
docs/cookbook/reviewing-persistence-type-changes.zh.md

@@ -8,65 +8,86 @@ description: "在创建 PR 前,本地生成、确认并验证会话持久化
 
 ## 概述
 
-在已安装依赖的贡献者检出目录中修改会话持久化类型声明后,使用本教程。你将检查结构差异、记录兼容性决策,并在本地运行与 CI 相同的检查。[记录参考](../persistence-changes/README.zh.md)解释文件和自动规则。所有比较输入都在检出目录中;不需要基线分支或网络访问。
+在已安装依赖的贡献者检出目录中修改会话持久化类型声明后,使用本教程。你将检查结构差异、提供双语兼容性说明,并在本地运行与 CI 相同的检查。[记录参考](../persistence-changes/README.zh.md)解释文件和自动规则。所有比较输入都在检出目录中;不需要基线分支或网络访问。
 
 ## 目录
 
-- [1. 生成当前清单](#generate)
-- [2. 创建确认记录](#acknowledge)
-- [3. 说明并验证变更](#verify)
-- [4. 解决竞争记录](#competing-records)
+- [1. 检查变更](#generate)
+- [2. 提供说明并创建记录](#acknowledge)
+- [3. 验证结果](#verify)
+- [4. 更新尚未接受的记录](#competing-records)
 - [开发备注](#dev-note)
 
 -----
 
 <a id="generate"></a>
-## 1. 生成当前清单
+## 1. 检查变更
 
 编辑类型及其消费方后,在仓库根目录运行:
 
 ```sh
-pnpm run gen-persistence-catalog
-pnpm run verify-persistence-changes
+pnpm --silent run verify-persistence-changes --json
 ```
 
-审阅生成的[目录](../persistence-catalog.zh.md)和 [schema 清单](../persistence-schema.json)。嵌套引用类型可能影响多个事件摘要;检查所有报告的根。重新生成不等于确认变更。在历史覆盖新 schema 之前,验证会报告变更路径并失败
+阅读报告中的根、路径、变更种类和版本要求。被引用类型可能影响多个事件摘要;检查每个受影响的根。在历史覆盖新 schema 之前,验证会失败。陈旧生成清单也会导致验证失败;记录命令会刷新它。若只改变展示细节且 `changes` 为空,运行 `pnpm run gen-persistence-catalog`;无需新增确认记录
 
 <a id="acknowledge"></a>
-## 2. 创建确认记录
+## 2. 提供说明并创建记录
+
+根据[兼容性规则](../persistence-changes/README.zh.md#compatibility-rules)选择决策。编写包含 `en` 和 `zh` 的本地 JSON 文件,两者分别包含 `summary`、`compatibility` 和 `verification` 字符串。以下输入描述一个经过验证的钩子审计字段从必选改为可选的变更。用你所做变更的事实替换说明和测试证据;CLI(命令行界面)不会证明这些声明。
+
+将输入保存为 `.artifacts/persistence-change.prose.json`,必要时创建该被忽略的目录:
+
+```json
+{
+  "en": {
+    "summary": "Makes the persisted hook audit decision optional.",
+    "compatibility": "Existing records remain valid. Hook execution consumes HookOutput instead of replaying this audit field. Producers still write decisions, and absence does not imply pass.",
+    "verification": "pnpm exec vitest run packages/hooks/hook-protocol/tests/events.spec.ts: 10 tests passed."
+  },
+  "zh": {
+    "summary": "将持久化的钩子审计决策改为可选。",
+    "compatibility": "已有记录仍然有效。钩子执行消费 HookOutput,不回放此审计字段。写入方仍然记录决策,缺失不代表 pass。",
+    "verification": "pnpm exec vitest run packages/hooks/hook-protocol/tests/events.spec.ts:10 个测试通过。"
+  }
+}
+```
 
-根据[兼容性规则](../persistence-changes/README.zh.md#compatibility-rules)选择决策。对于可选事件体新增,用日期和描述性短名替换示例 id:
+用日期和描述性短名替换示例 id:
 
 ```sh
-pnpm run persistence-changes --record 2026-09-11-optional-display-metadata --decision same-version
+pnpm --silent run persistence-changes --record 2026-09-11-poc-optional --decision same-version --prose .artifacts/persistence-change.prose.json --json
 ```
 
-命令在 [persistence-changes](../persistence-changes/README.zh.md) 中创建英文和中文记录草稿,以及生成的 schema 伴随文件。每个受影响的根引用其最新记录前驱。变更后 schema 和摘要来自源码;不要手工编辑。
+命令创建记录对和完整的变更后 schema,更新两份生成目录与机器清单,并记录双语配对。提交 `files` 中列出的文件前,审阅人工说明和生成差异。说明输入是编写用文件;生成的文档保留说明内容。省略 `--prose` 会创建未完成草稿,验证将拒绝它们,直到说明补齐
 
-对于需要升版本的变更,先遵循[添加会话格式版本](adding-a-session-format-version.zh.md),再使用 `--decision version-bump`。记录必须包含其自身的 `SessionHeader.version` 递增转换。无关的历史升版本不能授权本次变更。不要为日常变更创建另一条基线。
+对于需要升版本的变更,先遵循[添加会话格式版本](adding-a-session-format-version.zh.md),再使用 `--decision version-bump`。记录必须包含其自身的 `SessionHeader.version` 递增转换。无关的历史升版本不能授权本次变更。日常变更创建另一条基线。
 
 <a id="verify"></a>
-## 3. 说明并验证变更
-
-替换两种语言中生成的兼容性和验证占位内容。保持机器声明相同。仅解释报告的类型变更,并记录实际运行的聚焦测试结果。对于可选新增,审阅旧记录能否缺少该数据,以及旧读取器能否忽略它而不改变回放;自动分类不能证明该说明。
+## 3. 验证结果
 
-根据生成的英文变更更新目录的中文对侧文件。确认两个双语对,然后验证:
+本地与 CI 运行相同检查:
 
 ```sh
-pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-pnpm run verify-translation-pairing --write docs/persistence-changes/2026-09-11-optional-display-metadata.md
-pnpm run verify-persistence-catalog
-pnpm run verify-persistence-changes
+pnpm --silent run verify-persistence-changes --json
 ```
 
-在配对命令中使用实际记录 id。当生成文件与源码一致、每次转换满足其分类要求、每个当前根与最终历史状态一致时,验证通过。在 PR(Pull Request)中包含生成文件、记录对和两个一致性伴随记录。运行由变更代码决定的其他检查;本检查不能替代行为测试或迁移验证。
+生成清单与源码一致、每次转换满足其分类要求、当前根与最终历史状态一致时,成功响应报告 `ok: true`。失败响应报告 `ok: false`,退出码为 1;JSON 可解析不代表成功。响应包含 `operation`、`message`、结构化 `changes`、`roots` 中逐根的变更前后摘要、生成的 `files`,以及适用时的失败 `code`。每个变更都有稳定的 `kind`;自动化不需要解析描述文本。
+
+运行由变更代码决定的其他检查,包括配套人工文档的检查。记录生成负责其目录和记录的双语对;包 README 或其他双语页面的编辑仍遵循常规配对流程。持久化类型检查不能替代行为测试或迁移验证。
 
 <a id="competing-records"></a>
-## 4. 解决竞争记录
+## 4. 更新尚未接受的记录
+
+记录后源码再次变化时,审阅兼容性说明,并刷新同一条尚未接受的末端记录:
+
+```sh
+pnpm --silent run persistence-changes --update 2026-09-11-poc-optional --decision same-version --prose .artifacts/persistence-change.prose.json --json
+```
 
-集成时若报告一个根的前驱有两个后继,保留已接受记录,并使用新 id 在其基础上重新创建尚未接受的记录。重新评估最终组合的类型变更并重跑检查。无关根的记录无需刷新。
+命令刷新机器声明、schema、目录和配对。没有 `--prose` 时,它保留已有说明。更新会拒绝初始基线和被其他记录依赖的记录。目录本身无法识别哪些记录已获审阅接受:保留已接受历史,并创建后继
 
-若生成草稿后源码再次变化,只丢弃该未接受草稿及其伴随文件,重新生成清单,再创建替代记录。保留已接受历史。后续类型编辑不能沿用对另一摘要的确认。[机制决策](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)解释为何保留完整快照和逐根前驱。
+集成产生竞争末端记录时,根据剩余历史更新尚未接受的记录,再重新评估最终差异。无关根的确认无需刷新。[机制决策](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)解释为何保留完整快照和逐根前驱。
 
 <a id="dev-note"></a>
 ## 开发备注

+ 2 - 2
docs/i18n/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 docs/i18n/README.md
-README.md: 2ff8fc62f21d58a4d31b8aadd80c7a0c14556e6d
-README.zh.md: 2e0c183f6f9374c08d698fbd7109eb4e59f87c10
+README.md: f9b6b97d51541a12315c8b4503bd9b4399220888
+README.zh.md: 1f0ab74d3fbb8301dedb3c396c1b65334ffc8503

+ 5 - 1
docs/i18n/README.md

@@ -43,7 +43,11 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
 
 **Scope**: the root `CONTRIBUTING.md`, `BRAND_GUIDELINES.md`, and `SAFETY.md` documents, every non-vendor README, and every active document under `.agents/notes/**`, `docs/**`, and `python/**`. README matching is case-insensitive on the basename and covers future directories without another manifest edit. Dependency and ignored build-output trees and the frozen `.agents/notes/archived/` tree are discovery exclusions, not evolving translation source.
 
-Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. Their generators remain the English source of truth, and freshness and pairing gates enforce their respective invariants independently; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. A generator that owns both sides, such as the Cordis subsystem-region generator, projects paired document paths to each output locale while keeping every other generated byte equal. Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A generated page's Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.
+Generated English references and graphs participate in pairing when a reviewed Chinese counterpart is available. When a generator owns only English, it remains that source of truth; regeneration that changes English leaves the pair out of sync until the reviewed Chinese counterpart is updated and re-recorded. Freshness and pairing gates enforce their respective invariants independently.
+
+Shared generated regions, such as the Cordis subsystem regions, keep every generated byte identical across languages except localized paired-document paths. A full-page generator can instead render source-owned paired prose, such as [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts), from the same structural data with matching Markdown structure and identical code blocks. It regenerates both pages and their consistency record together; review still verifies the paired prose's meaning and terminology.
+
+Generated English sources omit the language switcher that ordinary authored sources carry, because adding it would make the generator stale; their Chinese counterparts still link back to the English source. A manually maintained Chinese counterpart may rewrite only self-referential generation and maintenance statements that would otherwise be false for the reviewed translation; all technical content remains subject to the ordinary faithfulness rules.
 
 **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them):
 

+ 5 - 1
docs/i18n/README.zh.md

@@ -45,7 +45,11 @@
 
 **范围**:根目录 `CONTRIBUTING.md`、`BRAND_GUIDELINES.md` 与 `SAFETY.md` 文档、除 vendor 源码外的全部 README,以及 `.agents/notes/**`、`docs/**` 与 `python/**` 下的全部活跃文档。匹配 README 时只看文件名且不区分大小写,因此今后新增的目录无需再修改 manifest。依赖目录、被忽略的构建产物目录以及冻结的 `.agents/notes/archived/` 目录树只在发现阶段排除,不属于持续演进的翻译源文档。
 
-有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器仍是英文真源,新鲜度门禁与配对门禁各自独立强制其约束;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。Cordis subsystem 区块生成器等同时拥有两侧输出的生成器,会把配对文档路径投影到各自 locale,同时保持其余生成字节一致。生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。生成页的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。
+有经评审的中文对侧的生成英文参考文档和图文档遵循配对规则。生成器只负责英文时,它仍是该语言的真源;重新生成导致英文变化后,配对会保持失去同步状态,直至经评审的中文对侧完成更新并重新记录。新鲜度门禁与配对门禁各自独立强制其约束。
+
+共享生成区域(例如 Cordis 子系统区域)在两种语言间保持每个生成字节相同,仅配对文档路径按语言本地化。整页生成器则可以从相同的结构数据渲染源码维护的双语说明,例如 [persistence-catalog-text.ts](../../scripts/persistence-catalog-text.ts),并保持 Markdown 结构对应、代码块完全相同。它同时重新生成两页及其一致性记录;评审仍须验证双语说明的含义与术语。
+
+生成的英文源文件不含普通撰写文档所带的语言切换行,因为添加该行会使生成器新鲜度检查失败;中文对侧仍链接回英文源。人工维护的中文对侧只能改写若直译便不再符合经评审译文事实的自指生成与维护说明;所有技术内容仍受普通忠实性规则约束。
 
 **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):
 

+ 2 - 2
docs/persistence-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/persistence-catalog.md
-persistence-catalog.md: 19c90b25554fff7a68d2ee7d52a7ad62fc6bdf5f
-persistence-catalog.zh.md: 1efb4308196a90acd19945e249880a787814d14b
+persistence-catalog.md: 99bd075eeabde138bcf258340a42380995bff5cb
+persistence-catalog.zh.md: a2ed30012e3ed70c1190ed3a64cdc9639a24109b

Dosya farkı çok büyük olduğundan ihmal edildi
+ 387 - 383
docs/persistence-catalog.md


Dosya farkı çok büyük olduğundan ihmal edildi
+ 389 - 385
docs/persistence-catalog.zh.md


+ 2 - 2
docs/persistence-changes/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 docs/persistence-changes/README.md
-README.md: e94c1c2b21030d3868c94e8646db2bb5e56206c9
-README.zh.md: d08825ef62a9445adc4a2ff563276c02c6d83f1c
+README.md: 08ead5d0dccabcd16cfebdcbc040289eecbac552
+README.zh.md: d735809da823aa8716f2e38c22b74c8a52e38afc

+ 3 - 3
docs/persistence-changes/README.md

@@ -22,7 +22,7 @@ Use this reference to inspect an acknowledged Session persistence-type change an
 <a id="files-and-ownership"></a>
 ## Files and ownership
 
-The generated [catalog](../persistence-catalog.md) provides readable declarations and digests; the [schema inventory](../persistence-schema.json) contains the normalized types. Each root represents the Session header, the event envelope, or one repository-declared event. Referenced types contribute transitively to each affected root's digest.
+The generated [catalog](../persistence-catalog.md) provides readable declarations and digests; the [schema inventory](../persistence-schema.json) contains the normalized types. Roots cover the logical Session header, the physical JSONL header line, the event envelope, and every repository-declared event. Referenced types contribute transitively to each affected root's digest.
 
 Each dated record has four sibling files:
 
@@ -33,7 +33,7 @@ Each dated record has four sibling files:
 | `YYYY-MM-DD-slug.i18n.yaml` | Generated bilingual consistency record |
 | `YYYY-MM-DD-slug.schema.json` | Generated complete after schemas for the affected roots that remain present |
 
-The [record template](../../.agents/skills/dsh-doc/templates/persistence-change.md) defines the authored format. The verifier reads the machine declaration once from the English file and checks the Chinese declaration for equality. A declaration names each affected root, its predecessor record, its after digest, and its compatibility decision. A new root has no predecessor; a deletion has no after schema and retains an explicit tombstone.
+The [record template](../../.agents/skills/dsh-doc/templates/persistence-change.md) defines the authored format. Record creation accepts a bilingual prose input and generates the machine declaration, snapshots, catalog pair, and consistency records. The verifier reads the machine declaration once from the English file and checks the Chinese declaration for equality. A declaration names each affected root, its predecessor record, its after digest, and its compatibility decision. A new root has no predecessor; a deletion has no after schema and retains an explicit tombstone.
 
 <a id="compatibility-rules"></a>
 ## Compatibility rules
@@ -55,7 +55,7 @@ A same-version explanation states why old records can omit the addition and why
 
 One baseline records the complete initial inventory. Later records use their predecessor's after schema as the before schema. The verifier rejects missing predecessors, cycles, duplicate successors for one root, digest mismatches, and current roots that disagree with their latest records. Independent roots can advance independently. Two changes to the same predecessor require a single ordered history after integration.
 
-Accepted records describe historical transitions; preserve their machine declarations and schema snapshots when adding a successor. Verification proves consistency within the tree, not that history was never rewritten. No Git ref, remote service, or released checkout supplies the baseline.
+Accepted records describe historical transitions; preserve their machine declarations and schema snapshots when adding a successor. An unaccepted terminal record can be refreshed explicitly; the command rejects baselines and records with dependants. The tree cannot establish review acceptance. Verification proves consistency within the tree, not that history was never rewritten. No Git ref, remote service, or released checkout supplies the baseline.
 
 Digests describe declared persistence types, not runtime validation or behavior. Comments, source locations, alias names, and property order do not affect them. Opaque types such as `unknown` expose no hidden structure to compare. Behavior-only changes and structures hidden inside opaque values are outside this mechanism's scope. The [decision](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.md) records these trade-offs.
 

+ 3 - 3
docs/persistence-changes/README.zh.md

@@ -22,7 +22,7 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc
 <a id="files-and-ownership"></a>
 ## 文件与职责
 
-生成的[目录](../persistence-catalog.zh.md)提供可读声明和摘要;[schema 清单](../persistence-schema.json)包含规范化类型。每个根表示会话头、事件封装或一个仓库内声明的事件。被引用类型的变更会传递到每个受影响根的摘要。
+生成的[目录](../persistence-catalog.zh.md)提供可读声明和摘要;[schema 清单](../persistence-schema.json)包含规范化类型。根覆盖逻辑会话头、物理 JSONL 头行、事件封装以及每个仓库内声明的事件。被引用类型的变更会传递到每个受影响根的摘要。
 
 每条带日期的记录有四个同目录文件:
 
@@ -33,7 +33,7 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc
 | `YYYY-MM-DD-slug.i18n.yaml` | 生成的双语一致性记录 |
 | `YYYY-MM-DD-slug.schema.json` | 生成的完整变更后 schema,覆盖受影响且仍然存在的根 |
 
-[记录模板](../../.agents/skills/dsh-doc/templates/persistence-change.md)定义人工编写的格式。验证器从英文文件读取一次机器声明,并检查中文声明是否相同。声明列出每个受影响的根、其前驱记录、变更后摘要和兼容性决策。新根没有前驱;删除操作没有变更后 schema,并保留显式删除标记。
+[记录模板](../../.agents/skills/dsh-doc/templates/persistence-change.md)定义人工编写的格式。创建记录时可以提供双语说明输入,由命令生成机器声明、快照、目录对和一致性记录。验证器从英文文件读取一次机器声明,并检查中文声明是否相同。声明列出每个受影响的根、其前驱记录、变更后摘要和兼容性决策。新根没有前驱;删除操作没有变更后 schema,并保留显式删除标记。
 
 <a id="compatibility-rules"></a>
 ## 兼容性规则
@@ -55,7 +55,7 @@ description: "审阅和维护已记录的会话持久化类型变更、对应 sc
 
 一条基线记录完整的初始清单。后续记录使用前驱的变更后 schema 作为变更前 schema。验证器拒绝缺失前驱、环、同一根的重复后继、摘要不匹配,以及当前根与最新记录不一致的情况。独立的根可以独立演进。同一前驱上的两个变更在集成后需要形成一条有序历史。
 
-已接受记录描述历史转换;添加后继时须保留其机器声明和 schema 快照。验证证明目录内的一致性,不证明历史从未被改写。基线不依赖 Git 引用、远端服务或已发布版本的检出目录。
+已接受记录描述历史转换;添加后继时须保留其机器声明和 schema 快照。尚未接受的末端记录可以显式刷新;命令拒绝基线及被依赖的记录。目录本身无法证明审阅接受状态。验证证明目录内的一致性,不证明历史从未被改写。基线不依赖 Git 引用、远端服务或已发布版本的检出目录。
 
 摘要描述声明的持久化类型,不描述运行时验证或行为。注释、源码位置、别名名称和属性顺序不影响摘要。`unknown` 等不透明类型不提供可比较的内部结构。纯行为变更以及不透明值中隐藏的结构不在本机制范围内。[决策记录](../../.agents/notes/implemented/process/2026-09-11-persistence-type-history.zh.md)说明这些取舍。
 

Dosya farkı çok büyük olduğundan ihmal edildi
+ 82 - 543
docs/persistence-schema.json


+ 44 - 31
scripts/gen-persistence-catalog.ts

@@ -14,6 +14,8 @@ import {
   type AnnotatedLogEventEntry, type EventEnvelopeTypeEntry,
 } from './persistence-catalog-source.ts'
 import { extractPersistenceSchema } from './persistence-schema.ts'
+import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
+import { renderPersistencePair, type PersistenceArtifact } from './persistence-artifacts.ts'
 
 export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
 export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
@@ -53,24 +55,27 @@ const LINK_MAP: Record<string, string> = {
 }
 
 /** Render the cross-link "Types:" line for a payload, or '' if none apply. */
-function typeLinks(payload: string): string {
+function typeLinks(payload: string, locale: PersistenceCatalogLocale): string {
   const seen = new Set<string>()
   for (const name of Object.keys(LINK_MAP)) {
     if (new RegExp(`\\b${name}\\b`).test(payload)) seen.add(name)
   }
   if (seen.size === 0) return ''
-  const links = [...seen].sort().map(n => `[${n}](${LINK_MAP[n]})`)
-  return `Types: ${links.join(' · ')}`
+  const links = Object.entries(LINK_MAP).filter(([name]) => seen.has(name)).sort(([left], [right]) => left.localeCompare(right))
+    .map(([name, path]) => {
+      return `[${name}](${locale === 'zh' ? path.replace(/\.md$/u, '.zh.md') : path})`
+    })
+  return `${persistenceCatalogText[locale].types}${links.join(' · ')}`
 }
 
 /** Render one log event entry. */
-function renderEvent(e: AnnotatedLogEventEntry): string[] {
+function renderEvent(e: AnnotatedLogEventEntry, locale: PersistenceCatalogLocale): string[] {
   const heading = `${e.name} — ${e.surface ? 'surface' : 'log-only'}`
   const out = [`<a id="${githubSlug(heading)}"></a>`, '', `#### \`${e.name}\` — ${e.surface ? 'surface' : 'log-only'}`, '']
   out.push('```' + FENCE, e.declaration, '```', '')
-  const links = typeLinks(e.payload)
+  const links = typeLinks(e.payload, locale)
   if (links) out.push(links, '')
-  out.push(`Source: [\`${e.source}\`](../${e.source.split(':')[0]})`, '')
+  out.push(`${persistenceCatalogText[locale].source}[\`${e.source}\`](../${e.source.split(':')[0]})`, '')
   return out
 }
 
@@ -79,39 +84,42 @@ export function render(
   events: AnnotatedLogEventEntry[],
   envelopeTypes: EventEnvelopeTypeEntry[],
   schema?: PersistenceSchemaInventory,
+  locale: PersistenceCatalogLocale = 'en',
 ): string {
+  const text = persistenceCatalogText[locale]
   const lines: string[] = [
     '<!-- Generated by scripts/gen-persistence-catalog.ts — do not edit by hand.',
     '     Run `pnpm run gen-persistence-catalog` to regenerate. -->',
     '',
-    '# Session Persistence Event Catalog',
+    `# ${text.title}`,
     '',
-    'Every event type that can appear in a session\'s durable event log: the complete persisted `SessionEvent` envelope and each member of the merge-extensible `SessionEventMap` — the owning vocabulary in `@deepseek-ai/dsh-session` plus every plugin declaration merge into `@deepseek-ai/dsh-session/types` in this repo — with source JSDoc, full payload declaration, surface badge, and declaration site. It complements [session.md](subsystems/session.md) (surface ordering and the `deriveMessages()` projection), [persistence.md](subsystems/persistence.md) (how the log is made durable), and the generated region of [session.md](subsystems/session.md#cordis-surface) (the live bus wiring — a log event is NOT a cordis event; it reaches listeners via the single `session/event` emit).',
+    ...(locale === 'zh' ? ['[English](persistence-catalog.md) | 中文', ''] : []),
+    text.intro,
     '',
-    'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. The archived [persistence-log-catalog record](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md) documents the original catalog decision.',
+    text.generation,
     '',
-    'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`). Current writers stamp `SESSION_FORMAT_VERSION`; supported historical artifacts reach this current vocabulary through the build-static adjacent migration catalog ([the version lifecycle](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further current-version event types, which are outside this catalog by construction and require an explicit disposition at a later format edge.',
+    text.envelopeIntro,
     '',
-    ...(schema ? [renderPersistenceSchemaIndex(schema)] : []),
-    '## Event envelope',
+    ...(schema ? [renderPersistenceSchemaIndex(schema, locale)] : []),
+    `## ${text.envelope}`,
     '',
     '```' + FENCE,
     envelopeTypes.map(entry => entry.declaration).join('\n\n'),
     '```',
     '',
-    `Sources: ${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
+    `${text.sources}${envelopeTypes.map(entry => `[\`${entry.source}\`](../${entry.source.split(':')[0]})`).join(' · ')}`,
     '',
-    '## Events',
+    `## ${text.events}`,
     '',
   ]
   const scopes = [...new Set(events.map(e => e.scope))].sort()
   for (const scope of scopes) {
     lines.push(`### \`${scope}/*\``, '')
     for (const e of events.filter(x => x.scope === scope).sort((a, b) => a.name.localeCompare(b.name))) {
-      lines.push(...renderEvent(e))
+      lines.push(...renderEvent(e, locale))
     }
   }
-  if (schema) lines.push(renderPersistenceSchemaDefinitions(schema))
+  if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale))
   return lines.join('\n')
 }
 
@@ -151,28 +159,33 @@ export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string
   ].join('\n')
 }
 
-/** One generated artifact: repo-relative target and its freshly-rendered content. */
-interface GeneratedArtifact {
-  readonly out: string
-  readonly content: string
+/**
+ * Render the complete generated persistence reference from one extracted inventory.
+ * @param scanRoot - checkout whose source event declarations supply JSDoc.
+ * @param schema - already extracted source schemas; shared with record validation.
+ * @returns both catalog languages, pairing metadata, runtime names and machine schemas.
+ */
+export function persistenceCatalogArtifacts(scanRoot: string, schema: PersistenceSchemaInventory): PersistenceArtifact[] {
+  const events = annotateSurface(collectLogEvents(scanRoot), collectSurfaceEventTypes(scanRoot))
+  const envelope = collectEventEnvelopeTypes(scanRoot)
+  return [
+    ...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
+    { path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
+    { path: OUT_SCHEMA, content: `${JSON.stringify(schema, null, 2)}\n` },
+  ]
 }
 
 /** CLI entry: default writes the artifacts, `--check` fails if a committed copy
  * is stale. Guarded behind an entry-point check so importing this module for
  * tests neither regenerates the committed files nor calls process.exit. */
 function main(): void {
-  const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes())
   const schema = extractPersistenceSchema(root)
-  const artifacts: GeneratedArtifact[] = [
-    { out: OUT, content: render(events, collectEventEnvelopeTypes(), schema) },
-    { out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
-    { out: OUT_SCHEMA, content: `${JSON.stringify(schema, null, 2)}\n` },
-  ]
+  const artifacts = persistenceCatalogArtifacts(root, schema)
   if (process.argv.includes('--check')) {
     const stale = artifacts.filter((artifact) => {
       let committed: string | null = null
       try {
-        committed = readFileSync(resolve(root, artifact.out), 'utf8')
+        committed = readFileSync(resolve(root, artifact.path), 'utf8')
       } catch {
         // Only ENOENT (not yet generated) is expected; a present-but-unreadable
         // file is not a state this repo produces. Either way the remedy is the
@@ -182,16 +195,16 @@ function main(): void {
       return committed !== artifact.content
     })
     if (stale.length === 0) {
-      console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`)
+      console.log(`gen-persistence-catalog: ${artifacts.map(a => a.path).join(', ')} are up to date.`)
       process.exit(0)
     }
-    console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
+    console.error(`gen-persistence-catalog: ${stale.map(a => a.path).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`)
     process.exit(1)
   }
 
   for (const artifact of artifacts) {
-    writeFileSync(resolve(root, artifact.out), artifact.content)
-    console.log(`gen-persistence-catalog: wrote ${artifact.out}.`)
+    writeFileSync(resolve(root, artifact.path), artifact.content)
+    console.log(`gen-persistence-catalog: wrote ${artifact.path}.`)
   }
 }
 

+ 53 - 0
scripts/persistence-artifacts.ts

@@ -0,0 +1,53 @@
+/** Render complete persistence documentation pairs without Git or file mutation. */
+
+import { readFileSync } from 'node:fs'
+import { hasLanguageSwitcher } from './translation-links.ts'
+import { translationPairPaths } from './translation-pairing-record.ts'
+import {
+  blobHash, languageSwitcherTargets, parseTranslationMarkdown, parseTranslationPairingManifest,
+  renderPairMeta, requiresSourceLanguageSwitcher, translationPairSourcePredicate,
+  translationStructureDiff, translationStructureSignature,
+} from './translation-pairing.ts'
+
+const isTranslationPairSource = translationPairSourcePredicate(parseTranslationPairingManifest(
+  readFileSync(new URL('./translation-pairing.manifest.json', import.meta.url), 'utf8'),
+))
+
+/** One repository-relative generated file and its complete UTF-8 content. */
+export interface PersistenceArtifact {
+  readonly path: string
+  readonly content: string
+}
+
+/**
+ * Check a pair's code, structure and localized links, then render its three files.
+ * Link existence remains the Markdown gate's responsibility. Pair hashes are
+ * computed in-process; staging the documents stores the corresponding Git blobs.
+ * @param root - checkout root used to resolve relative link identities.
+ * @param source - repository-relative English document path.
+ * @param en - complete authored or generated English Markdown.
+ * @param zh - complete authored or generated Chinese Markdown.
+ * @returns the documents and their matching consistency sidecar, without writing files.
+ */
+export function renderPersistencePair(root: string, source: string, en: string, zh: string): PersistenceArtifact[] {
+  const paths = translationPairPaths(source)
+  const sourceTree = parseTranslationMarkdown(en)
+  const zhTree = parseTranslationMarkdown(zh)
+  const sourceTargets = languageSwitcherTargets(paths.source)
+  const zhTargets = languageSwitcherTargets(paths.zh)
+  if (!hasLanguageSwitcher(zhTree, zh, sourceTargets)
+    || requiresSourceLanguageSwitcher(source) && !hasLanguageSwitcher(sourceTree, en, zhTargets)) {
+    throw new Error(`${source}: both authored languages need their counterpart switcher`)
+  }
+  const context = { repoRoot: root, isTranslationPairSource, repositoryFileExists: () => true }
+  const errors = translationStructureDiff(
+    translationStructureSignature(sourceTree, zhTargets, { ...context, sourcePath: paths.source, markdown: en }),
+    translationStructureSignature(zhTree, sourceTargets, { ...context, sourcePath: paths.zh, markdown: zh }),
+  )
+  if (errors.length > 0) throw new Error(`${source}: bilingual structure mismatch: ${errors.join('; ')}`)
+  return [
+    { path: paths.source, content: en },
+    { path: paths.zh, content: zh },
+    { path: paths.meta, content: renderPairMeta(paths.source, blobHash(Buffer.from(en)), paths.zh, blobHash(Buffer.from(zh))) },
+  ]
+}

+ 43 - 0
scripts/persistence-catalog-text.ts

@@ -0,0 +1,43 @@
+/** Authored language pairs for generated persistence reference prose. */
+
+/** Language of a generated persistence reference. */
+export type PersistenceCatalogLocale = 'en' | 'zh'
+
+const english = {
+  title: 'Session Persistence Event Catalog',
+  intro: 'Every repository-declared durable Session event appears here with its source declaration and resolved types. The catalog covers the logical and physical headers, event envelopes, and every plugin declaration merge. See [Session](subsystems/session.md) for replay and [persistence](subsystems/persistence.md) for storage.',
+  generation: 'Run `pnpm run gen-persistence-catalog` to regenerate both catalog languages, their pairing record, the known-event module, and the machine schema inventory. `pnpm run verify-persistence-catalog` checks all generated files. Declaration fences preserve source JSDoc and type references; resolved definitions expose their transitive structure.',
+  envelopeIntro: 'The envelope carries `type`, `seq`, `time`, `data`, optional `ignorable`, and conditional `surfaceOp` / `sourceEventSeqs`. A **surface** event produces model history; a **log-only** event does not. The inventory covers this repository; external plugin types require their own declarations and are outside this catalog.',
+  envelope: 'Event envelope', events: 'Events', sources: 'Sources: ', source: 'Source: ', types: 'Types: ',
+  fingerprints: 'Persistence type fingerprints',
+  fingerprintsIntro: 'The [machine inventory](persistence-schema.json) contains every reachable normalized type and its SHA-256 digest. Root digests include referenced types. Comments, source locations, alias names, erased brands, readonly markers, and object property order do not affect these fingerprints. Tuple order, property names, value types, and optionality do.',
+  historyIntro: 'The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.',
+  rootColumns: '| Root | Kind | SHA-256 | Resolved type |',
+  definitions: 'Resolved persistence types',
+  definitionsIntro: 'Each definition appears once. References preserve sharing and recursion; the digest beside a definition includes its complete reachable structure. Source names and locations identify its declarations but are excluded from its digest.',
+  propertyColumns: '| Property | Presence | Type |', positionColumns: '| Position | Presence | Type |',
+  optional: 'optional', required: 'required', rest: 'rest', index: 'index signature',
+  emptyObject: 'Object with no declared properties.', arrayPrefix: 'Array of ', arraySuffix: '.',
+  oneOf: 'One of:', opaque: ' (opaque)', opaqueExplanation: ": the declaration does not expose the stored value's internal fields.",
+}
+
+const chinese: Record<keyof typeof english, string> = {
+  title: '会话持久化事件目录',
+  intro: '本目录列出仓库声明的每个持久化 Session 事件及其源码声明和解析类型,覆盖逻辑与物理 header、事件信封以及各插件的声明合并。回放规则参见 [Session](subsystems/session.zh.md),存储规则参见[持久化](subsystems/persistence.zh.md)。',
+  generation: '运行 `pnpm run gen-persistence-catalog` 可重新生成目录的两种语言、配对记录、已知事件模块和机器 schema 目录。`pnpm run verify-persistence-catalog` 检查所有生成文件。声明围栏保留源码 JSDoc 和类型引用;解析后的定义展开其传递引用结构。',
+  envelopeIntro: '信封包含 `type`、`seq`、`time`、`data`、可选的 `ignorable` 以及条件字段 `surfaceOp` / `sourceEventSeqs`。**surface** 事件产生模型历史,**log-only** 事件不产生模型历史。目录覆盖本仓库;外部插件类型需要独立声明,不属于本目录。',
+  envelope: '事件信封', events: '事件', sources: '来源:', source: '来源:', types: '类型:',
+  fingerprints: '持久化类型指纹',
+  fingerprintsIntro: '[机器可读目录](persistence-schema.json)包含所有可达的规范化类型及其 SHA-256 摘要。根类型的摘要涵盖引用类型。注释、源码位置、别名、擦除的品牌标记、readonly 标记和对象属性声明顺序不影响摘要;元组顺序、属性名称、值类型和可选性会影响摘要。',
+  historyIntro: '[变更记录](persistence-changes/README.zh.md)通过保存在本源码树中的快照确认精确的类型转换。按照[评审流程](cookbook/reviewing-persistence-type-changes.zh.md)分类并记录变更。这些检查覆盖已声明的类型结构;不透明载荷的内部内容和未改变类型的行为变更不在检查范围内。',
+  rootColumns: '| 根类型 | 类别 | SHA-256 | 已解析类型 |',
+  definitions: '已解析的持久化类型',
+  definitionsIntro: '每个类型定义仅列出一次。引用保留共享和递归关系;定义旁的摘要涵盖其完整可达结构。源码名称和位置标识声明来源,但不参与摘要计算。',
+  propertyColumns: '| 属性 | 存在性 | 类型 |', positionColumns: '| 位置 | 存在性 | 类型 |',
+  optional: '可选', required: '必需', rest: '剩余项', index: '索引签名',
+  emptyObject: '无已声明属性的对象。', arrayPrefix: '', arraySuffix: ' 的数组。',
+  oneOf: '以下类型之一:', opaque: '(不透明)', opaqueExplanation: ':此声明未暴露存储值的内部字段。',
+}
+
+/** Complete translated prose; adding an English key requires its Chinese counterpart. */
+export const persistenceCatalogText = { en: english, zh: chinese }

+ 206 - 9
scripts/persistence-changes.spec.ts

@@ -6,18 +6,42 @@ import { tmpdir } from 'node:os'
 import { join, resolve } from 'node:path'
 import { afterEach, describe, expect, it } from 'vitest'
 import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
-import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode } from './persistence-schema-model.ts'
+import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty } from './persistence-schema-model.ts'
 import { extractPersistenceSchema } from './persistence-schema.ts'
+import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
 import {
   classifyPersistenceChange,
   loadPersistenceHistory,
   parsePersistenceSnapshot,
-  runPersistenceChanges,
+  runPersistenceChanges as executePersistenceChanges,
   validatePersistenceHistory,
   verifyPersistenceChanges,
 } from './persistence-changes.ts'
 import type { PersistenceChangeRecord, PersistenceHistoryEntry } from './persistence-changes.ts'
 
+function runPersistenceChanges(
+  args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
+): string {
+  return executePersistenceChanges(args, root, extract, (_root, current) => [{
+    path: 'docs/persistence-schema.json', content: JSON.stringify(current, null, 2) + '\n',
+  }])
+}
+
+function jsonResult(source: string): { ok: boolean; files: readonly string[] } {
+  return JSON.parse(source) as { ok: boolean; files: readonly string[] }
+}
+
+const AUTHORED_PROSE = {
+  en: { summary: 'Adds optional metadata.', compatibility: 'Readers may omit the metadata.', verification: 'The focused tests passed.' },
+  zh: { summary: '添加可选元数据。', compatibility: '读取方可省略元数据。', verification: '定向测试通过。' },
+}
+
+function proseFile(root: string): string {
+  const path = join(root, 'prose.json')
+  writeFileSync(path, JSON.stringify(AUTHORED_PROSE))
+  return path
+}
+
 const roots: string[] = []
 afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
 
@@ -101,11 +125,24 @@ function baseline(root: string, schema = inventory()): void {
   commitCurrent(root, schema)
 }
 
+function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoot {
+  const nodes: SchemaNode[] = [
+    { kind: 'object', indices: [], properties: [{ name: 'data', type: 1, optional: false }] },
+    { kind: 'union', types: arms.map((_, index) => 5 + index) },
+    { kind: 'primitive', type: 'string' },
+    { kind: 'primitive', type: 'number' },
+    { kind: 'primitive', type: 'boolean' },
+    ...arms.map(properties => ({ kind: 'object' as const, indices: [], properties })),
+  ]
+  const schema = canonicalizeSchema(nodes, 0)
+  return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
+}
+
 describe('persistence change classification', () => {
   it('treats a new optional payload subtree as one additive change even with required descendants', () => {
     const before = typeRoot('event:example/value', { value: 'string' })
     const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } })
-    expect(classifyPersistenceChange(before, after)).toEqual([{ path: 'event:example/value.data.details', description: 'optional property added', requiresVersionBump: false }])
+    expect(classifyPersistenceChange(before, after)).toEqual([{ path: 'event:example/value.data.details', kind: 'optional-property-added', description: 'optional property added', requiresVersionBump: false }])
   })
 
   it('permits required-to-optional payload properties while rejecting opposite changes, type changes, and removals', () => {
@@ -136,6 +173,65 @@ describe('persistence change classification', () => {
     expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
   })
 
+  it('allows optional additions across undiscriminated union arms', () => {
+    const first = [{ name: 'a', type: 2, optional: false }]
+    const second = [{ name: 'b', type: 3, optional: false }]
+    const extra = { name: 'x', type: 4, optional: true }
+    const differences = classifyPersistenceChange(unionBody([first, second]), unionBody([[...first, extra], [...second, extra]]))
+    expect(differences).toEqual([expect.objectContaining({ path: 'event:example/value.data.x', requiresVersionBump: false })])
+  })
+
+  it('allows required-to-optional fields across undiscriminated union arms', () => {
+    const before = unionBody([[{ name: 'a', type: 2, optional: false }], [{ name: 'b', type: 3, optional: false }]])
+    const after = unionBody([[{ name: 'a', type: 2, optional: true }], [{ name: 'b', type: 3, optional: true }]])
+    const differences = classifyPersistenceChange(before, after)
+    expect(differences.map(change => change.path).sort()).toEqual(['event:example/value.data.a', 'event:example/value.data.b'])
+    expect(differences.every(change => change.kind === 'property-made-optional' && !change.requiresVersionBump)).toBe(true)
+  })
+
+  it('finds a complete matching when one union arm has multiple additive successors', () => {
+    const required = [{ name: 'a', type: 2, optional: false }]
+    const optional = [{ name: 'a', type: 2, optional: true }]
+    const flexible = [{ name: '0', type: 4, optional: true }, ...optional]
+    const constrained = [...required, { name: 'z', type: 4, optional: true }]
+    const before = unionBody([required, optional])
+    for (const after of [unionBody([flexible, constrained]), unionBody([constrained, flexible])]) {
+      const differences = classifyPersistenceChange(before, after)
+      expect(differences.length).toBeGreaterThan(0)
+      expect(differences.every(change => !change.requiresVersionBump)).toBe(true)
+    }
+    const noCompleteMatching = unionBody([flexible, [{ name: 'different', type: 4, optional: true }]])
+    expect(classifyPersistenceChange(before, noCompleteMatching).some(change => change.requiresVersionBump)).toBe(true)
+  })
+
+  it('keeps union cardinality, property removal, new required fields, and value types strict', () => {
+    const first = [{ name: 'a', type: 2, optional: false }]
+    const second = [{ name: 'b', type: 3, optional: false }]
+    const third = [{ name: 'c', type: 4, optional: false }]
+    const before = unionBody([first, second])
+    const variants = [
+      unionBody([first, second, third]),
+      unionBody([first]),
+      unionBody([first, third]),
+      unionBody([[...first, { name: 'x', type: 4, optional: false }], [...second, { name: 'x', type: 4, optional: true }]]),
+      unionBody([[{ name: 'a', type: 3, optional: true }], [{ name: 'b', type: 3, optional: true }]]),
+    ]
+    for (const after of variants) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
+  })
+
+  it('checks all recursive union arms without retaining provisional successes between candidates', () => {
+    const next = { name: 'next', type: 1, optional: true }
+    const first = [{ name: 'a', type: 2, optional: false }, next]
+    const second = [{ name: 'b', type: 3, optional: false }, next]
+    const extra = { name: 'x', type: 4, optional: true }
+    const before = unionBody([first, second])
+    const additive = unionBody([[...first, extra], [...second, extra]])
+    expect(classifyPersistenceChange(before, additive).every(change => !change.requiresVersionBump)).toBe(true)
+    const invalid = unionBody([[...first, extra], [{ name: 'b', type: 2, optional: false }, next, extra]])
+    expect(classifyPersistenceChange(before, invalid).some(change => change.requiresVersionBump)).toBe(true)
+    expect(classifyPersistenceChange(invalid, before).some(change => change.requiresVersionBump)).toBe(true)
+  })
+
   it('preserves body scope through arrays and tuples and rejects a simultaneous value-type change', () => {
     function wrapped(optional: boolean, primitive: 'string' | 'number' = 'string'): PersistenceRoot {
       const nodes: SchemaNode[] = [
@@ -232,6 +328,7 @@ describe('persistence changes current-tree commands', () => {
     runPersistenceChanges(['--baseline', BASE_ID], root, () => before)
     expect(() => loadPersistenceHistory(root)).toThrow('complete compatibility')
     finishDocuments(root, BASE_ID)
+    rmSync(join(root, 'docs/persistence-schema.json'))
     expect(() => verifyPersistenceChanges(root, before)).toThrow('is missing')
     commitCurrent(root, before)
     const after = inventory({ value: 'string', 'label?': 'string' })
@@ -257,6 +354,79 @@ describe('persistence changes current-tree commands', () => {
     expect(() => loadPersistenceHistory(root)).toThrow('unreferenced')
   })
 
+  it('authors a complete pair from explicit prose and reports source changes even when generated artifacts are stale', () => {
+    const root = fixture()
+    baseline(root)
+    const after = inventory({ value: 'string', 'label?': 'string' })
+    const pending = jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after))
+    expect(pending).toMatchObject({ ok: false, operation: 'check', code: 'stale-artifacts',
+      roots: [{ root: 'event:example/value', kind: 'event', before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }], changes: [
+        { root: 'event:example/value', path: 'event:example/value.data.label', kind: 'optional-property-added', requiresVersionBump: false },
+      ] })
+    const written = jsonResult(runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', proseFile(root), '--json'], root, () => after))
+    expect(written).toMatchObject({ ok: true, operation: 'record', recordId: NEXT_ID })
+    expect(written.files).toEqual([
+      'docs/persistence-schema.json', `docs/persistence-changes/${NEXT_ID}.md`, `docs/persistence-changes/${NEXT_ID}.zh.md`,
+      `docs/persistence-changes/${NEXT_ID}.i18n.yaml`, `docs/persistence-changes/${NEXT_ID}.schema.json`,
+    ])
+    expect(written).toMatchObject({ roots: [{ root: 'event:example/value', kind: 'event',
+      before: inventory().roots[2]?.digest, after: after.roots[2]?.digest }] })
+    expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).toContain(AUTHORED_PROSE.en.compatibility)
+    expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => after)).ok).toBe(true)
+  })
+
+  it('refreshes only a terminal acknowledgement while preserving its authored prose and predecessor', () => {
+    const root = fixture()
+    baseline(root)
+    const after = inventory({ value: 'string', 'label?': 'string' })
+    const prose = proseFile(root)
+    runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
+    const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
+    const beforeText = readFileSync(path, 'utf8')
+    const refreshed = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number' })
+    runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version'], root, () => refreshed)
+    const stripRecord = (value: string): string => value.replace(/```yaml persistence-change[\s\S]*?```/u, '')
+    expect(stripRecord(readFileSync(path, 'utf8'))).toBe(stripRecord(beforeText))
+    expect(loadPersistenceHistory(root).tips.get('event:example/value')?.id).toBe(NEXT_ID)
+    expect(loadPersistenceHistory(root).entries.find(entry => entry.record.id === NEXT_ID)?.record.changes[0]?.previous).toBe(BASE_ID)
+    expect(jsonResult(runPersistenceChanges(['--check', '--json'], root, () => refreshed)).ok).toBe(true)
+    const final = inventory({ value: 'string', 'label?': 'string', 'extra?': 'number', 'last?': 'boolean' })
+    runPersistenceChanges(['--record', '2026-09-11-successor', '--decision', 'same-version', '--prose', prose], root, () => final)
+    expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version'], root, () => final)).toThrow('with successors')
+    expect(() => runPersistenceChanges(['--update', BASE_ID, '--decision', 'same-version'], root, () => final)).toThrow('cannot update the persistence baseline')
+  })
+
+  it('completes a scaffold through update and rejects invalid prose or decisions before changing files', () => {
+    const root = fixture()
+    baseline(root)
+    const after = inventory({ value: 'string', 'label?': 'string' })
+    runPersistenceChanges(['--record', NEXT_ID, '--decision', 'same-version'], root, () => after)
+    const prose = proseFile(root)
+    runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
+    expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
+    expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')).not.toContain('TODO:')
+    const pairedPaths = ['.md', '.zh.md', '.i18n.yaml'].map(suffix => join(root, `docs/persistence-changes/${NEXT_ID}${suffix}`))
+    const completed = pairedPaths.map(path => readFileSync(path, 'utf8'))
+    runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)
+    expect(pairedPaths.map(path => readFileSync(path, 'utf8'))).toEqual(completed)
+    const path = join(root, `docs/persistence-changes/${NEXT_ID}.md`)
+    const before = readFileSync(path, 'utf8')
+    const invalid = inventory({ value: 'number' })
+    const result = jsonResult(runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--json'], root, () => invalid))
+    expect(result).toMatchObject({ ok: false, code: 'version-bump-required', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })] })
+    expect(readFileSync(path, 'utf8')).toBe(before)
+    for (const value of [
+      { ...AUTHORED_PROSE, extra: 'unsupported' },
+      { ...AUTHORED_PROSE, en: { ...AUTHORED_PROSE.en, compatibility: '   ' } },
+      { ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: 'TODO: record validation evidence.' } },
+      { ...AUTHORED_PROSE, zh: { ...AUTHORED_PROSE.zh, verification: '```text\nUnpaired code.\n```' } },
+    ]) {
+      writeFileSync(prose, JSON.stringify(value))
+      expect(() => runPersistenceChanges(['--update', NEXT_ID, '--decision', 'same-version', '--prose', prose], root, () => after)).toThrow()
+      expect(readFileSync(path, 'utf8')).toBe(before)
+    }
+  })
+
   it('executes the real CLI against a source-only temporary checkout without Git history', () => {
     const root = fixture()
     const physical = join(root, 'packages/session/session-persistence-jsonl/src')
@@ -272,31 +442,58 @@ describe('persistence changes current-tree commands', () => {
     const source = [
       '/** Stored header. */', 'export interface SessionHeader { version: 3; id: string }',
       '/** Stored event payloads. */', 'export interface SessionEventMap {', '/** Saved value. */',
-      "'example/value': { value: string }", '}', 'export type SurfaceEventType = never',
+      "'example/value': { value: string }", '}', '/** Surface event names. */', "export type SurfaceEventType = 'example/value'",
+      '/** Persisted event names. */', 'export type SessionEventType = keyof SessionEventMap',
+      '/** Surface placement. */', "export type SurfaceOp = 'append'", '/** Persisted event. */',
       'export type SessionEvent<K extends keyof SessionEventMap = keyof SessionEventMap> =',
-      '  { [P in K]: { type: P; seq: number; data: SessionEventMap[P] } }[K]', '',
+      '  { [P in K]: { type: P; seq: number; data: SessionEventMap[P]; surfaceOp: SurfaceOp } }[K]', '',
     ].join('\n')
     writeFileSync(join(session, 'types.ts'), source)
     const script = resolve(import.meta.dirname, 'persistence-changes.ts')
     const cli = (...args: string[]): ReturnType<typeof spawnSync> => spawnSync(process.execPath, ['--import', import.meta.resolve('tsx/esm'), script, '--root', root, ...args], {
       cwd: root, encoding: 'utf8', timeout: 120_000,
     })
-    const initialized = cli('--baseline', BASE_ID)
+    const prose = proseFile(root)
+    const initialized = cli('--baseline', BASE_ID, '--prose', prose)
     expect(initialized.error).toBeUndefined()
     expect(initialized.signal).toBeNull()
     expect(initialized.status, String(initialized.stderr)).toBe(0)
-    finishDocuments(root, BASE_ID)
-    commitCurrent(root, extractPersistenceSchema(root))
     const accepted = cli('--check')
     expect(accepted.error).toBeUndefined()
     expect(accepted.signal).toBeNull()
     expect(accepted.status, String(accepted.stderr)).toBe(0)
-    writeFileSync(join(session, 'types.ts'), source.replace('value: string', 'value: number'))
+    const optional = source.replace('value: string', 'value: string; label?: string')
+    writeFileSync(join(session, 'types.ts'), optional)
+    const authored = cli('--record', NEXT_ID, '--decision', 'same-version', '--prose', prose, '--json')
+    expect(authored.error).toBeUndefined()
+    expect(authored.signal).toBeNull()
+    expect(authored.status, String(authored.stderr)).toBe(0)
+    expect(JSON.parse(String(authored.stdout)) as unknown).toMatchObject({ ok: true, operation: 'record' })
+    const beforeUpdate = readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')
+    writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number'))
+    const updated = cli('--update', NEXT_ID, '--decision', 'same-version', '--json')
+    expect(updated.error).toBeUndefined()
+    expect(updated.signal).toBeNull()
+    expect(updated.status, String(updated.stderr)).toBe(0)
+    expect(JSON.parse(String(updated.stdout)) as unknown).toMatchObject({ ok: true, operation: 'update' })
+    expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8').replace(/```yaml persistence-change[\s\S]*?```/u, ''))
+      .toBe(beforeUpdate.replace(/```yaml persistence-change[\s\S]*?```/u, ''))
+    const generated = extractPersistenceSchema(root)
+    for (const file of persistenceCatalogArtifacts(root, generated)) expect(readFileSync(join(root, file.path), 'utf8')).toBe(file.content)
+    verifyPersistenceChanges(root, generated)
+    writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number').replace('value: string', 'value: number'))
     commitCurrent(root, extractPersistenceSchema(root))
     const rejected = cli('--check')
     expect(rejected.error).toBeUndefined()
     expect(rejected.signal).toBeNull()
     expect(rejected.status).toBe(1)
     expect(String(rejected.stderr)).toContain('version-bump required')
+    const structured = cli('--check', '--json')
+    expect(structured.error).toBeUndefined()
+    expect(structured.signal).toBeNull()
+    expect(structured.status).toBe(1)
+    expect(JSON.parse(String(structured.stdout)) as unknown).toMatchObject({
+      ok: false, code: 'unacknowledged-changes', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })],
+    })
   })
 })

+ 319 - 106
scripts/persistence-changes.ts

@@ -7,6 +7,9 @@ import { JSON_SCHEMA, load } from 'js-yaml'
 import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
 import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
 import { extractPersistenceSchema } from './persistence-schema.ts'
+import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
+import { renderPersistencePair } from './persistence-artifacts.ts'
+import type { PersistenceArtifact } from './persistence-artifacts.ts'
 
 const HISTORY_DIRECTORY = 'docs/persistence-changes'
 const CURRENT_SCHEMA = 'docs/persistence-schema.json'
@@ -42,11 +45,76 @@ export interface PersistenceHistoryEntry {
 
 /** One detected type change, with a path that reviewers can locate. */
 export interface PersistenceTypeChange {
+  readonly kind: PersistenceTypeChangeKind
   readonly path: string
   readonly description: string
   readonly requiresVersionBump: boolean
 }
 
+const CHANGE_DESCRIPTIONS = {
+  'root-added': 'root added',
+  'root-removed': 'root removed',
+  'root-classification-changed': 'root classification changed',
+  'type-changed': 'type changed',
+  'property-removed': 'property removed',
+  'property-made-optional': 'property made optional',
+  'property-made-required': 'property made required',
+  'optional-property-added': 'optional property added',
+  'required-property-added': 'required property added',
+  'index-signature-changed': 'index signature changed',
+  'tuple-length-changed': 'tuple length changed',
+  'tuple-element-cardinality-changed': 'tuple element cardinality changed',
+  'union-variants-changed': 'union variants changed',
+} as const
+
+/** Stable structural classification independent of diagnostic prose. */
+export type PersistenceTypeChangeKind = keyof typeof CHANGE_DESCRIPTIONS
+
+/** Author-supplied paragraphs used to complete one language of a change record. */
+export interface PersistenceChangeProse {
+  readonly summary: string
+  readonly compatibility: string
+  readonly verification: string
+}
+
+/** Explicit bilingual prose; the CLI supplies no compatibility or validation claims. */
+export interface PersistenceChangeProsePair {
+  readonly en: PersistenceChangeProse
+  readonly zh: PersistenceChangeProse
+}
+
+interface ReportedChange extends PersistenceTypeChange {
+  readonly root: string
+}
+
+interface RootTransition {
+  readonly root: string
+  readonly kind: PersistenceRoot['kind']
+  readonly before: string | null
+  readonly after: string | null
+}
+
+class PersistenceChangeFailure extends Error {
+  constructor(
+    message: string, readonly code: string, readonly changes: readonly ReportedChange[] = [],
+    readonly roots: readonly RootTransition[] = [],
+  ) {
+    super(message)
+  }
+}
+
+interface CommandResult {
+  readonly schemaVersion: 1
+  readonly ok: boolean
+  readonly operation: 'check' | 'baseline' | 'record' | 'update'
+  readonly message: string
+  readonly changes: readonly ReportedChange[]
+  readonly roots: readonly RootTransition[]
+  readonly files: readonly string[]
+  readonly recordId?: string
+  readonly code?: string
+}
+
 interface Tip {
   readonly id: string
   readonly root: PersistenceRoot | null
@@ -232,21 +300,26 @@ function subDigest(schema: CanonicalSchema, node: number): string {
   return schemaDigest(canonicalizeSchema(schema.nodes, node))
 }
 
-function matchingDiscriminants(before: CanonicalSchema, oldIndex: number, after: CanonicalSchema, newIndex: number): boolean {
-  const oldNode = before.nodes[oldIndex]
-  const newNode = after.nodes[newIndex]
-  if (oldNode?.kind !== 'object' || newNode?.kind !== 'object') return false
-  let shared = false
-  for (const property of oldNode.properties) {
-    const value = before.nodes[property.type]
-    if (property.optional || value?.kind !== 'literal') continue
-    const next = newNode.properties.find(item => item.name === property.name && !item.optional)
-    const nextValue = next === undefined ? undefined : after.nodes[next.type]
-    if (nextValue?.kind !== 'literal') continue
-    if (value.value !== nextValue.value) return false
-    shared = true
+function matchUnionVariants(candidates: readonly (readonly number[])[]): number[] | undefined {
+  const owners = new Map<number, number>()
+  function assign(previous: number, visited: Set<number>): boolean {
+    for (const next of candidates[previous] ?? []) {
+      if (visited.has(next)) continue
+      visited.add(next)
+      const owner = owners.get(next)
+      if (owner === undefined || assign(owner, visited)) {
+        owners.set(next, previous)
+        return true
+      }
+    }
+    return false
+  }
+  for (let previous = 0; previous < candidates.length; previous += 1) {
+    if (!assign(previous, new Set())) return undefined
   }
-  return shared
+  const matches: number[] = []
+  for (const [next, previous] of owners) matches[previous] = next
+  return matches
 }
 
 /** Classify structural differences; only optional payload properties and ordinary event additions are additive.
@@ -256,92 +329,99 @@ function matchingDiscriminants(before: CanonicalSchema, oldIndex: number, after:
  */
 export function classifyPersistenceChange(before: PersistenceRoot | null, after: PersistenceRoot | null): PersistenceTypeChange[] {
   if (before === null) {
-    return after === null ? [] : [{ path: after.key, description: 'root added',
+    return after === null ? [] : [{ path: after.key, kind: 'root-added', description: 'root added',
       requiresVersionBump: after.kind !== 'event' || after.surface !== false }]
   }
-  if (after === null) return [{ path: before.key, description: 'root removed', requiresVersionBump: true }]
+  if (after === null) return [{ path: before.key, kind: 'root-removed', description: 'root removed', requiresVersionBump: true }]
   const key = after.key
   const oldRoot = before
   const newRoot = after
+  const describe = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): PersistenceTypeChange =>
+    ({ path, kind, description: CHANGE_DESCRIPTIONS[kind], requiresVersionBump })
   const changes: PersistenceTypeChange[] = []
-  const add = (path: string, description: string, requiresVersionBump = true): void => {
-    changes.push({ path, description, requiresVersionBump })
-  }
-  if (before.kind !== after.kind || before.surface !== after.surface) add(key, 'root classification changed')
+  if (before.kind !== after.kind || before.surface !== after.surface) changes.push(describe(key, 'root-classification-changed'))
   if (before.digest === after.digest) return changes
-  const seen = new Set<string>()
   const fingerprints = [new Map<number, string>(), new Map<number, string>()] as const
   const fingerprint = (schema: CanonicalSchema, index: number, side: 0 | 1): string => {
     let result = fingerprints[side].get(index)
     if (result === undefined) { result = subDigest(schema, index); fingerprints[side].set(index, result) }
     return result
   }
-  function compare(oldIndex: number, newIndex: number, path: string, body: boolean): void {
-    if (fingerprint(oldRoot.schema, oldIndex, 0) === fingerprint(newRoot.schema, newIndex, 1)) return
-    const pair = `${oldIndex}:${newIndex}:${String(body)}`
-    if (seen.has(pair)) return
-    seen.add(pair)
+  type Scope = 'event' | 'body' | 'strict'
+  function compare(
+    oldIndex: number, newIndex: number, path: string, scope: Scope, ancestors: ReadonlySet<string>,
+  ): PersistenceTypeChange[] {
+    if (fingerprint(oldRoot.schema, oldIndex, 0) === fingerprint(newRoot.schema, newIndex, 1)) return []
+    const pair = `${oldIndex}:${newIndex}:${scope}`
+    if (ancestors.has(pair)) return []
+    // Recursive pairs are assumptions for this candidate only. A failed sibling
+    // or unmatched union arm cannot leave a cached success for another candidate.
+    const active = new Set(ancestors).add(pair)
+    const differences: PersistenceTypeChange[] = []
+    const add = (path: string, kind: PersistenceTypeChangeKind, requiresVersionBump = true): void => {
+      differences.push(describe(path, kind, requiresVersionBump))
+    }
+    const descend = (oldType: number, newType: number, child: string, childScope: Scope): void => {
+      differences.push(...compare(oldType, newType, child, childScope, active))
+    }
     const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode
     const newNode = newRoot.schema.nodes[newIndex] as SchemaNode
-    if (oldNode.kind !== newNode.kind) { add(path, 'type changed'); return }
+    if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')]
     if (oldNode.kind === 'object' && newNode.kind === 'object') {
       const oldProps = new Map(oldNode.properties.map(property => [property.name, property]))
       const newProps = new Map(newNode.properties.map(property => [property.name, property]))
       for (const [name, property] of oldProps) {
         const next = newProps.get(name)
         const child = `${path}.${name}`
-        if (next === undefined) { add(child, 'property removed'); continue }
-        if (property.optional !== next.optional) add(child, next.optional ? 'property made optional' : 'property made required', !body || !next.optional)
-        compare(property.type, next.type, child, body || oldRoot.kind === 'event' && path === key && name === 'data')
+        if (next === undefined) { add(child, 'property-removed'); continue }
+        if (property.optional !== next.optional) add(child, next.optional ? 'property-made-optional' : 'property-made-required', scope !== 'body' || !next.optional)
+        descend(property.type, next.type, child, scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict')
       }
       for (const [name, property] of newProps) {
-        if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional property added' : 'required property added', !body || !property.optional)
+        if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional-property-added' : 'required-property-added', scope !== 'body' || !property.optional)
       }
-      const oldIndices = new Map(oldNode.indices.map(entry => [subDigest(oldRoot.schema, entry.key), entry]))
-      const newIndices = new Map(newNode.indices.map(entry => [subDigest(newRoot.schema, entry.key), entry]))
-      if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(index => !newIndices.has(index))) add(path, 'index signature changed')
+      const oldIndices = new Map(oldNode.indices.map(entry => [fingerprint(oldRoot.schema, entry.key, 0), entry]))
+      const newIndices = new Map(newNode.indices.map(entry => [fingerprint(newRoot.schema, entry.key, 1), entry]))
+      if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(index => !newIndices.has(index))) add(path, 'index-signature-changed')
       for (const [index, entry] of oldIndices) {
         const next = newIndices.get(index)
-        if (next !== undefined) compare(entry.value, next.value, `${path}[*]`, body)
+        if (next !== undefined) descend(entry.value, next.value, `${path}[*]`, scope === 'body' ? 'body' : 'strict')
       }
-      return
+      return differences
+    }
+    if (oldNode.kind === 'array' && newNode.kind === 'array') {
+      return compare(oldNode.element, newNode.element, `${path}[]`, scope === 'body' ? 'body' : 'strict', active)
     }
-    if (oldNode.kind === 'array' && newNode.kind === 'array') { compare(oldNode.element, newNode.element, `${path}[]`, body); return }
     if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') {
-      if (oldNode.elements.length !== newNode.elements.length) { add(path, 'tuple length changed'); return }
+      if (oldNode.elements.length !== newNode.elements.length) return [describe(path, 'tuple-length-changed')]
       for (const [index, element] of oldNode.elements.entries()) {
         const next = newNode.elements[index] as SchemaTupleElement
-        if (element.optional !== next.optional || element.rest !== next.rest) add(`${path}[${index}]`, 'tuple element cardinality changed')
-        compare(element.type, next.type, `${path}[${index}]`, body)
+        if (element.optional !== next.optional || element.rest !== next.rest) add(`${path}[${index}]`, 'tuple-element-cardinality-changed')
+        descend(element.type, next.type, `${path}[${index}]`, scope === 'body' ? 'body' : 'strict')
       }
-      return
+      return differences
     }
     if (oldNode.kind === 'union' && newNode.kind === 'union') {
+      if (oldNode.types.length !== newNode.types.length) return [describe(path, 'union-variants-changed')]
+      const candidates = oldNode.types.map(oldType => newNode.types.map(newType => compare(oldType, newType, path, scope, active)))
+      const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
+        candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
+      if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
       const oldTypes = new Map(oldNode.types.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
       const newTypes = new Map(newNode.types.map(index => [fingerprint(newRoot.schema, index, 1), index]))
       const removed = [...oldTypes].filter(([hash]) => !newTypes.has(hash)).map(([, index]) => index)
       const added = [...newTypes].filter(([hash]) => !oldTypes.has(hash)).map(([, index]) => index)
-      if (removed.length === 1 && added.length === 1) compare(removed[0] as number, added[0] as number, path, body)
-      else if (removed.length === added.length) {
-        const paired = new Set<number>()
-        const pairs: Array<[number, number]> = []
-        for (const oldType of removed) {
-          const candidates = added.filter(newType => matchingDiscriminants(oldRoot.schema, oldType, newRoot.schema, newType))
-          if (candidates.length !== 1 || paired.has(candidates[0] as number)) { add(path, 'union variants changed'); return }
-          paired.add(candidates[0] as number)
-          pairs.push([oldType, candidates[0] as number])
-        }
-        for (const [oldType, newType] of pairs) compare(oldType, newType, path, body)
-      } else add(path, 'union variants changed')
-      return
+      if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
+      return [describe(path, 'union-variants-changed')]
     }
-    add(path, 'type changed')
+    return [describe(path, 'type-changed')]
   }
-  compare(0, 0, key, false)
-  return changes
+  changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set()))
+  if (changes.length === 0) changes.push(describe(key, 'type-changed'))
+  return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()]
 }
 
-function parseDocument(source: string, filename: string): PersistenceChangeRecord {
+function parseDocument(source: string, filename: string, allowIncomplete = false): PersistenceChangeRecord {
   const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(source)
   if (frontmatter === null || record(load(frontmatter[1] as string, { schema: JSON_SCHEMA }), filename).kind !== 'persistence-change') throw new Error(`${filename}: kind must be persistence-change`)
   const openings = [...source.matchAll(/^```yaml persistence-change\s*$/gmu)]
@@ -365,7 +445,7 @@ function parseDocument(source: string, filename: string): PersistenceChangeRecor
     if (change.decision !== 'same-version' && change.decision !== 'version-bump') throw new Error(`${filename}: invalid compatibility decision`)
   }
   if (roots.size === 0) throw new Error(`${filename}: changes must not be empty`)
-  if (source.includes(EXPLANATION_PLACEHOLDER) || source.includes(EVIDENCE_PLACEHOLDER)) throw new Error(`${filename}: complete compatibility and verification prose`)
+  if (!allowIncomplete && (source.includes(EXPLANATION_PLACEHOLDER) || source.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${filename}: complete compatibility and verification prose`)
   return input as unknown as PersistenceChangeRecord
 }
 
@@ -420,13 +500,21 @@ export function validatePersistenceHistory(entries: readonly PersistenceHistoryE
     if (!found.entry.record.baseline) {
       const differences = classifyPersistenceChange(before, after)
       if (differences.length === 0) throw new Error(`${id}: unchanged acknowledgement for ${root}`)
-      if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') throw new Error(`${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`)
+      if (differences.some(change => change.requiresVersionBump) && found.change.decision !== 'version-bump') {
+        throw new PersistenceChangeFailure(
+          `${id}: ${root} requires a format version bump (${differences.filter(change => change.requiresVersionBump).map(change => change.path + ': ' + change.description).join('; ')})`,
+          'version-bump-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)],
+        )
+      }
       if (found.change.decision === 'version-bump') {
         const header = found.entry.record.changes.find(change => change.root === 'SessionHeader')
         const oldHeader = header?.previous === null || header === undefined ? null : visit(header.previous, 'SessionHeader')
         const from = headerVersion(oldHeader)
         const to = headerVersion(found.entry.snapshot.roots.find(item => item.key === 'SessionHeader') ?? null)
-        if (from === undefined || to === undefined || to <= from) throw new Error(`${id}: version-bump requires this record's own increasing SessionHeader.version transition`)
+        if (from === undefined || to === undefined || to <= from) {
+          throw new PersistenceChangeFailure(`${id}: version-bump requires this record's own increasing SessionHeader.version transition`,
+            'version-transition-required', differences.map(change => ({ root, ...change })), [rootTransition(before, after)])
+        }
       }
     }
     states.set(key, 'visited')
@@ -448,6 +536,10 @@ export function validatePersistenceHistory(entries: readonly PersistenceHistoryE
  * @returns checked history without comparing its tips to current source.
  */
 export function loadPersistenceHistory(root: string): PersistenceHistory {
+  return validatePersistenceHistory(readPersistenceEntries(root))
+}
+
+function readPersistenceEntries(root: string, allowIncompleteId?: string): PersistenceHistoryEntry[] {
   const directory = join(root, HISTORY_DIRECTORY)
   if (!existsSync(directory)) throw new Error('persistence history is missing; use pnpm run persistence-changes --baseline ID for explicit initialization')
   const files = readdirSync(directory).sort()
@@ -455,7 +547,8 @@ export function loadPersistenceHistory(root: string): PersistenceHistory {
   const snapshots = new Set(files.filter(file => file.endsWith('.schema.json')))
   const entries = documents.map((filename) => {
     const source = readFileSync(join(directory, filename), 'utf8').replaceAll('\r\n', '\n')
-    const change = parseDocument(source, filename)
+    const allowIncomplete = filename === `${allowIncompleteId}.md`
+    const change = parseDocument(source, filename, allowIncomplete)
     const snapshotName = `${change.id}.schema.json`
     if (!snapshots.delete(snapshotName)) throw new Error(`${filename}: missing schema snapshot ${snapshotName}`)
     const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync(join(directory, snapshotName), 'utf8')))
@@ -465,11 +558,11 @@ export function loadPersistenceHistory(root: string): PersistenceHistory {
     const englishBlock = source.match(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/mu)?.[1]
     const chineseBlocks = [...translated.matchAll(/^```yaml persistence-change[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gmu)]
     if (chineseBlocks.length !== 1 || chineseBlocks[0]?.[1] !== englishBlock) throw new Error(`${filename}: bilingual machine records differ`)
-    if (translated.includes(EXPLANATION_PLACEHOLDER) || translated.includes(EVIDENCE_PLACEHOLDER)) throw new Error(`${translatedName}: complete compatibility and verification prose`)
+    if (!allowIncomplete && (translated.includes(EXPLANATION_PLACEHOLDER) || translated.includes(EVIDENCE_PLACEHOLDER))) throw new Error(`${translatedName}: complete compatibility and verification prose`)
     return { record: change, snapshot }
   })
   if (snapshots.size !== 0) throw new Error(`unreferenced persistence schema snapshot: ${[...snapshots].join(', ')}`)
-  return validatePersistenceHistory(entries)
+  return entries
 }
 
 function currentDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): string[] {
@@ -487,68 +580,142 @@ function currentDifferences(history: PersistenceHistory, current: PersistenceSch
  * @returns verified history.
  */
 export function verifyPersistenceChanges(root: string, current: PersistenceSchemaInventory): PersistenceHistory {
+  const history = loadPersistenceHistory(root)
+  const differences = reportedDifferences(history, current)
+  const transitions = rootTransitions(history, current)
   const committedPath = join(root, CURRENT_SCHEMA)
-  if (!existsSync(committedPath)) throw new Error(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`)
+  if (!existsSync(committedPath)) {
+    throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`, 'generated-artifact-missing', differences, transitions)
+  }
   const committed = parsePersistenceSnapshot(JSON.parse(readFileSync(committedPath, 'utf8')))
-  if (JSON.stringify(committed) !== JSON.stringify(current)) throw new Error(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`)
-  const history = loadPersistenceHistory(root)
-  const differences = currentDifferences(history, current)
+  if (JSON.stringify(committed) !== JSON.stringify(current)) {
+    throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
+  }
   if (differences.length !== 0) {
-    const details = differences.flatMap(key => classifyPersistenceChange(
-      history.tips.get(key)?.root ?? null, current.roots.find(item => item.key === key) ?? null,
-    ).map(change => `  ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`))
-    throw new Error(`unacknowledged persistence type changes:\n${details.join('\n')}`)
+    const details = differences.map(change => `  ${change.path}: ${change.description} (${change.requiresVersionBump ? 'version-bump required' : 'same-version allowed'})`)
+    throw new PersistenceChangeFailure(`unacknowledged persistence type changes:\n${details.join('\n')}`, 'unacknowledged-changes', differences, transitions)
   }
   return history
 }
 
+function rootTransition(before: PersistenceRoot | null, after: PersistenceRoot | null): RootTransition {
+  const root = (after ?? before) as PersistenceRoot
+  return { root: root.key, kind: root.kind, before: before?.digest ?? null, after: after?.digest ?? null }
+}
+
+function rootTransitions(history: PersistenceHistory | undefined, current: PersistenceSchemaInventory): RootTransition[] {
+  const changed = history === undefined ? current.roots.map(root => root.key) : currentDifferences(history, current)
+  return changed.map(root => rootTransition(history?.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null))
+}
+
+function reportedDifferences(history: PersistenceHistory, current: PersistenceSchemaInventory): ReportedChange[] {
+  return currentDifferences(history, current).flatMap(root => classifyPersistenceChange(
+    history.tips.get(root)?.root ?? null, current.roots.find(item => item.key === root) ?? null,
+  ).map(change => ({ root, ...change })))
+}
+
 function machineBlock(change: PersistenceChangeRecord): string {
   return ['```yaml persistence-change', 'schemaVersion: 1', `id: ${change.id}`, `baseline: ${String(change.baseline)}`, 'changes:',
     ...change.changes.flatMap(item => [`  - root: ${JSON.stringify(item.root)}`, `    previous: ${item.previous === null ? 'null' : JSON.stringify(item.previous)}`, `    after: ${item.after === null ? 'null' : JSON.stringify(item.after)}`, `    decision: ${item.decision}`]), '```'].join('\n')
 }
 
-function scaffold(change: PersistenceChangeRecord, chinese: boolean): string {
+function scaffold(change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
   const summary = chinese ? '概述' : 'Summary'
   const compatibility = chinese ? '兼容性' : 'Compatibility'
   const verification = chinese ? '验证' : 'Verification'
   return ['---', `description: ${JSON.stringify(chinese ? '记录持久化类型更改及其兼容性确认。' : 'Records a persistence type transition and its compatibility acknowledgement.')}`, 'kind: persistence-change', '---', '',
     `# ${change.id}`, '', chinese ? `[English](${change.id}.md) | 中文` : `English | [中文](${change.id}.zh.md)`, '',
-    `## ${summary}`, '', EXPLANATION_PLACEHOLDER, '', '## ' + (chinese ? '目录' : 'Table of Contents'), '',
+    `## ${summary}`, '', prose?.summary ?? EXPLANATION_PLACEHOLDER, '', '## ' + (chinese ? '目录' : 'Table of Contents'), '',
     `- [${chinese ? '声明' : 'Declaration'}](#declaration)`, `- [${compatibility}](#compatibility)`, `- [${verification}](#verification)`, `- [${chinese ? '开发备注' : 'Dev Note'}](#dev-note)`, '',
     '<a id="declaration"></a>', `## ${chinese ? '声明' : 'Declaration'}`, '', machineBlock(change), '',
-    '<a id="compatibility"></a>', `## ${compatibility}`, '', EXPLANATION_PLACEHOLDER, '',
-    '<a id="verification"></a>', `## ${verification}`, '', EVIDENCE_PLACEHOLDER, '', '<a id="dev-note"></a>', `## ${chinese ? '开发备注' : 'Dev Note'}`, '', chinese ? '无。' : 'None.', ''].join('\n')
+    '<a id="compatibility"></a>', `## ${compatibility}`, '', prose?.compatibility ?? EXPLANATION_PLACEHOLDER, '',
+    '<a id="verification"></a>', `## ${verification}`, '', prose?.verification ?? EVIDENCE_PLACEHOLDER, '', '<a id="dev-note"></a>', `## ${chinese ? '开发备注' : 'Dev Note'}`, '', chinese ? '无。' : 'None.', ''].join('\n')
 }
 
-/** Execute the current-tree verifier or explicitly scaffold one acknowledgement.
- * @param args - check, baseline, or record command arguments.
- * @param root - checkout root; defaults to this script's repository.
- * @param extract - current-source extraction function; fixtures supply their own source reader.
- * @returns a concise operation result.
+/** Parse explicit authored prose without supplying compatibility or validation claims.
+ * @param value - decoded JSON supplied through --prose.
+ * @returns complete English and Chinese section text.
  */
-export function runPersistenceChanges(
-  args: readonly string[],
-  root: string = resolve(import.meta.dirname, '..'),
-  extract: (root: string) => PersistenceSchemaInventory = extractPersistenceSchema,
-): string {
+export function parsePersistenceProse(value: unknown): PersistenceChangeProsePair {
+  const pair = record(value, 'persistence prose')
+  keys(pair, ['en', 'zh'], 'persistence prose')
+  for (const locale of ['en', 'zh']) {
+    const sections = record(pair[locale], `persistence prose ${locale}`)
+    keys(sections, ['summary', 'compatibility', 'verification'], `persistence prose ${locale}`)
+    for (const [name, value] of Object.entries(sections)) {
+      const text = textValue(value, `${locale}.${name}`)
+      if (text.trim().length === 0 || text.includes(EXPLANATION_PLACEHOLDER) || text.includes(EVIDENCE_PLACEHOLDER)) {
+        throw new Error(`${locale}.${name} requires authored prose without scaffold placeholders`)
+      }
+    }
+  }
+  return pair as unknown as PersistenceChangeProsePair
+}
+
+function updateDocument(source: string, change: PersistenceChangeRecord, chinese: boolean, prose?: PersistenceChangeProse): string {
+  source = source.replace(/^```yaml persistence-change[^\S\n]*\n[\s\S]*?^```[^\S\n]*$/mu, machineBlock(change))
+  if (prose === undefined) return source
+  const headings = chinese ? ['概述', '兼容性', '验证'] : ['Summary', 'Compatibility', 'Verification']
+  for (const [index, text] of [prose.summary, prose.compatibility, prose.verification].entries()) {
+    const lines = source.split('\n')
+    const heading = `## ${headings[index]}`
+    const start = lines.indexOf(heading)
+    if (start < 0 || lines.lastIndexOf(heading) !== start) throw new Error(`--prose requires one ${heading} section in the existing record`)
+    let end = lines.findIndex((line, lineIndex) => lineIndex > start && /^##? /u.test(line))
+    if (end < 0) end = lines.length
+    let anchor = end
+    while (anchor > start + 1 && lines[anchor - 1] === '') anchor -= 1
+    if (/^<a id="[^"]+"><\/a>$/u.test(lines[anchor - 1] ?? '')) end = anchor - 1
+    lines.splice(start + 1, end - start - 1, '', text.trim(), '')
+    source = lines.join('\n')
+  }
+  return source
+}
+
+function commandOperation(args: readonly string[]): CommandResult['operation'] {
+  if (args.some(arg => arg === '--baseline' || arg.startsWith('--baseline='))) return 'baseline'
+  if (args.some(arg => arg === '--record' || arg.startsWith('--record='))) return 'record'
+  if (args.some(arg => arg === '--update' || arg.startsWith('--update='))) return 'update'
+  return 'check'
+}
+
+function executeCommand(
+  args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
+  artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[],
+): CommandResult {
   const { values } = parseArgs({ args: [...args], strict: true, allowPositionals: false, options: {
-    check: { type: 'boolean' }, baseline: { type: 'string' }, record: { type: 'string' }, decision: { type: 'string' }, root: { type: 'string' },
+    check: { type: 'boolean' }, baseline: { type: 'string' }, record: { type: 'string' }, update: { type: 'string' },
+    decision: { type: 'string' }, root: { type: 'string' }, prose: { type: 'string' }, json: { type: 'boolean' },
   } })
   if (values.root !== undefined) root = resolve(values.root)
-  if ([values.check === true, values.baseline !== undefined, values.record !== undefined].filter(Boolean).length > 1) throw new Error('choose exactly one of --check, --baseline ID, or --record ID')
-  if (values.record === undefined && values.decision !== undefined) throw new Error('--decision requires --record')
+  const selected = [values.check === true, values.baseline !== undefined, values.record !== undefined, values.update !== undefined]
+  if (selected.filter(Boolean).length > 1) throw new Error('choose exactly one of --check, --baseline ID, --record ID, or --update ID')
+  if (values.record === undefined && values.update === undefined && values.decision !== undefined) throw new Error('--decision requires --record or --update')
+  const operation = commandOperation(args)
+  if (operation === 'check' && values.prose !== undefined) throw new Error('--prose requires --baseline, --record, or --update')
+  const prose = values.prose === undefined ? undefined : parsePersistenceProse(JSON.parse(readFileSync(resolve(root, values.prose), 'utf8')))
   const current = parsePersistenceSnapshot(extract(root))
-  if (values.baseline === undefined && values.record === undefined) {
+  if (operation === 'check') {
     const history = verifyPersistenceChanges(root, current)
-    return `persistence changes: ${current.roots.length} roots match ${history.entries.length} history records.`
+    return { schemaVersion: 1, ok: true, operation,
+      message: `persistence changes: ${current.roots.length} roots match ${history.entries.length} history records.`, changes: [], roots: [], files: [] }
   }
-  const baseline = values.baseline !== undefined
-  const id = identifier(values.baseline ?? values.record, 'record id')
+  const baseline = operation === 'baseline'
+  const update = operation === 'update'
+  const id = identifier(values.baseline ?? values.record ?? values.update, 'record id')
   const directory = join(root, HISTORY_DIRECTORY)
   if (baseline && existsSync(directory) && readdirSync(directory).some(file => file.endsWith('.schema.json') || ID_PATTERN.test(file.replace(/\.md$/u, '')))) throw new Error('persistence baseline already exists; baseline creation cannot reset history')
   const decision = baseline ? 'same-version' : values.decision
-  if (decision !== 'same-version' && decision !== 'version-bump') throw new Error('--record requires --decision same-version|version-bump')
-  const history = baseline ? undefined : loadPersistenceHistory(root)
+  if (decision !== 'same-version' && decision !== 'version-bump') throw new Error('--record and --update require --decision same-version|version-bump')
+  const entries = baseline ? [] : readPersistenceEntries(root, update ? id : undefined)
+  const existing = update ? entries.find(entry => entry.record.id === id) : undefined
+  if (update && existing === undefined) throw new Error(`${id}: cannot update a missing acknowledgement`)
+  if (existing?.record.baseline === true) throw new Error('cannot update the persistence baseline')
+  if (existing !== undefined && entries.some(entry => entry.record.changes.some(change => change.previous === id))) {
+    throw new Error(`${id}: cannot update an acknowledgement with successors`)
+  }
+  const prior = entries.filter(entry => entry !== existing)
+  const history = baseline ? undefined : validatePersistenceHistory(prior)
   const changed = baseline ? current.roots.map(root => root.key) : currentDifferences(history as PersistenceHistory, current)
   if (changed.length === 0) throw new Error('no persistence type changes to acknowledge')
   const roots = current.roots.filter(root => changed.includes(root.key))
@@ -557,21 +724,67 @@ export function runPersistenceChanges(
     after: roots.find(root => root.key === key)?.digest ?? null, decision,
   })) }
   const snapshot: PersistenceSchemaInventory = { formatVersion: 1, roots, types: [] }
-  validatePersistenceHistory([...(history?.entries ?? []), { record: change, snapshot }])
-  const outputs = [
-    [`${id}.md`, scaffold(change, false)],
-    [`${id}.zh.md`, scaffold(change, true)],
-    [`${id}.schema.json`, JSON.stringify(snapshot, null, 2) + '\n'],
-  ] as const
-  if (outputs.some(([file]) => existsSync(join(directory, file)))) throw new Error(`${id}: acknowledgement file already exists`)
-  mkdirSync(directory, { recursive: true })
-  for (const [file, content] of outputs) writeFileSync(join(directory, file), content, { flag: 'wx' })
-  return `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. Complete the compatibility and verification prose, then record translation pairing.`
+  validatePersistenceHistory([...prior, { record: change, snapshot }])
+  const document = (chinese: boolean): string => {
+    const supplied = chinese ? prose?.zh : prose?.en
+    return existing === undefined ? scaffold(change, chinese, supplied)
+      : updateDocument(readFileSync(join(directory, `${id}${chinese ? '.zh' : ''}.md`), 'utf8'), change, chinese, supplied)
+  }
+  const english = document(false)
+  const chinese = document(true)
+  parseDocument(english, `${id}.md`, prose === undefined && existing === undefined)
+  parseDocument(chinese, `${id}.md`, prose === undefined && existing === undefined)
+  const recordFiles = [
+    ...renderPersistencePair(root, `${HISTORY_DIRECTORY}/${id}.md`, english, chinese),
+    { path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: JSON.stringify(snapshot, null, 2) + '\n' },
+  ]
+  if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`)
+  const outputs = [...artifacts(root, current), ...recordFiles]
+  for (const file of outputs) mkdirSync(resolve(root, file.path, '..'), { recursive: true })
+  for (const file of outputs) writeFileSync(resolve(root, file.path), file.content, { flag: recordFiles.includes(file) && !update ? 'wx' : 'w' })
+  const completion = baseline
+    ? 'Complete both record documents and refresh their translation pairing.'
+    : `Complete the compatibility and verification prose with --update ${id} --prose FILE --decision ${decision}.`
+  const message = existing === undefined && prose === undefined
+    ? `Created ${HISTORY_DIRECTORY}/${id}.md and paired schema files. ${completion}`
+    : `${update ? 'Updated' : 'Created'} ${HISTORY_DIRECTORY}/${id}.md; schema artifacts and bilingual pairing are current.`
+  return { schemaVersion: 1, ok: true, operation, recordId: id, message,
+    changes: history === undefined ? [] : reportedDifferences(history, current),
+    roots: rootTransitions(history, current), files: outputs.map(file => file.path) }
+}
+
+/** Execute tree-only verification or author an explicit persistence acknowledgement.
+ * @param args - check, baseline, record, or update arguments; --json selects structured output.
+ * @param root - checkout root; defaults to this script's repository.
+ * @param extract - current-source extraction function; fixtures supply their own source reader.
+ * @param artifacts - renderer for current generated artifacts; fixtures may isolate the inventory artifact.
+ * @returns text or one JSON result; JSON failures retain ok:false for the process entry point.
+ */
+export function runPersistenceChanges(
+  args: readonly string[],
+  root: string = resolve(import.meta.dirname, '..'),
+  extract: (root: string) => PersistenceSchemaInventory = extractPersistenceSchema,
+  artifacts: (root: string, current: PersistenceSchemaInventory) => readonly PersistenceArtifact[] = persistenceCatalogArtifacts,
+): string {
+  try {
+    const result = executeCommand(args, root, extract, artifacts)
+    return args.includes('--json') ? JSON.stringify(result) : result.message
+  } catch (error: unknown) {
+    if (!args.includes('--json')) throw error
+    const result: CommandResult = { schemaVersion: 1, ok: false, operation: commandOperation(args),
+      message: error instanceof Error ? error.message : String(error),
+      code: error instanceof PersistenceChangeFailure ? error.code : 'verification-failed',
+      changes: error instanceof PersistenceChangeFailure ? error.changes : [],
+      roots: error instanceof PersistenceChangeFailure ? error.roots : [], files: [] }
+    return JSON.stringify(result)
+  }
 }
 
 if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
   try {
-    console.log(runPersistenceChanges(process.argv.slice(2)))
+    const output = runPersistenceChanges(process.argv.slice(2))
+    console.log(output)
+    if (process.argv.includes('--json') && !(JSON.parse(output) as { ok: boolean }).ok) process.exitCode = 1
   } catch (error: unknown) {
     console.error(error instanceof Error ? error.message : String(error))
     process.exitCode = 1

+ 55 - 0
scripts/persistence-schema.spec.ts

@@ -164,6 +164,61 @@ interface SessionEventMap {
     expect(changed.types.some(item => isArbitraryJsonSchema(item.schema))).toBe(false)
   })
 
+  it('rejects invalid reachable declaration files despite inherited skipLibCheck', () => {
+    const root = fixture("export type { Payload } from './declared.js'")
+    put(root, 'packages/domain/payload/src/declared.d.ts', 'type Bad = ReturnType<42>; export interface Payload {value: Bad}')
+    expect(() => extractPersistenceSchema(root)).toThrow('TS2344')
+  })
+
+  it('preserves authored declaration-file any without checking unrelated declarations', () => {
+    const root = fixture("export type { Payload } from './declared.js'")
+    put(root, 'packages/domain/payload/src/declared.d.ts', 'type Declared = any; export interface Payload {value: Declared}; type Unrelated = ReturnType<42>')
+    const model = extractPersistenceSchema(root)
+    expect(model.types.some(item => item.schema.nodes[0]?.kind === 'opaque' && item.schema.nodes[0].reason === 'any')).toBe(true)
+  })
+
+  it('locates named transitive definitions at their declarations instead of their references', () => {
+    const root = fixture("import type { Detail as ImportedDetail } from './detail.js'\nexport interface Payload { first: ImportedDetail; second: ImportedDetail }")
+    put(root, 'packages/domain/payload/src/detail.ts', '/** Detailed payload. */\nexport interface Detail { code: string; done: true }\n')
+    const model = extractPersistenceSchema(root)
+    const detail = model.types.find(item => item.names.includes('packages/domain/payload/src/detail.ts#Detail'))
+    expect(detail?.sources).toEqual(['packages/domain/payload/src/detail.ts:2'])
+  })
+
+  it('keeps shared scalar provenance at real aliases and omits plain property references', () => {
+    const root = fixture([
+      'type Label = string',
+      "type Done = 'done'",
+      'export interface Payload {',
+      '  label: Label',
+      '  other: string',
+      '  state: Done',
+      "  fallback: 'done'",
+      '  count: number',
+      '}',
+    ].join('\n'))
+    const model = extractPersistenceSchema(root)
+    const string = model.types.find(item => item.schema.nodes[0]?.kind === 'primitive' && item.schema.nodes[0].type === 'string')
+    const literal = model.types.find(item => item.schema.nodes[0]?.kind === 'literal' && item.schema.nodes[0].value === 'done')
+    const number = model.types.find(item => item.schema.nodes[0]?.kind === 'primitive' && item.schema.nodes[0].type === 'number')
+    expect(string?.names).toEqual(['packages/domain/payload/src/types.ts#Label'])
+    expect(string?.sources).toEqual(['packages/domain/payload/src/types.ts:1'])
+    expect(literal?.names).toEqual(['packages/domain/payload/src/types.ts#Done'])
+    expect(literal?.sources).toEqual(['packages/domain/payload/src/types.ts:2'])
+    expect(number?.names).toEqual([])
+    expect(number?.sources).toEqual([])
+  })
+
+  it('uses the declaration of an anonymous object literal and never its containing property', () => {
+    const root = fixture('export interface Payload {\n  detail:\n    { code: string }\n}')
+    const model = extractPersistenceSchema(root)
+    const detail = model.types.find((item) => {
+      const node = item.schema.nodes[0]
+      return node?.kind === 'object' && node.properties.length === 1 && node.properties[0]?.name === 'code'
+    })
+    expect(detail?.sources).toEqual(['packages/domain/payload/src/types.ts:3'])
+  })
+
   it.each([
     ['missing type', 'export interface Payload {value: Missing}', 'TS2304'],
     ['callable value', 'export interface Payload {value: () => void}', 'callable data'],

+ 51 - 23
scripts/persistence-schema.ts

@@ -65,6 +65,8 @@ export function extractPersistenceSchema(root: string): PersistenceSchemaInvento
     rootDir: root,
     noUnusedLocals: false,
     noUnusedParameters: false,
+    // Reachable declaration-file errors must not become opaque any values.
+    skipLibCheck: false,
     strict: true,
     exactOptionalPropertyTypes: true,
   }
@@ -93,7 +95,6 @@ export function extractPersistenceSchema(root: string): PersistenceSchemaInvento
     if (found === undefined) throw new PersistenceSchemaError(`persistence schema: missing compiler root ${name}`)
     return found
   }
-  const extraction = new SchemaExtractor(root, program)
   const eventNamesDeclaration = declaration('EventNamesRoot')
   const compiledEvents = stringLiterals(checker.getTypeFromTypeNode(eventNamesDeclaration.type), 'keyof SessionEventMap')
   const discoveredEvents = new Set(events.map(event => event.name))
@@ -108,11 +109,6 @@ export function extractPersistenceSchema(root: string): PersistenceSchemaInvento
     if (!events.some(event => event.name === name)) throw new PersistenceSchemaError(`persistence schema: surface event ${name} has no declaration`)
   }
   const header = declaration('HeaderRoot')
-  const roots: RootInput[] = [{
-    key: 'SessionHeader',
-    kind: 'header',
-    node: extraction.convert(checker.getTypeFromTypeNode(header.type), header),
-  }]
   const physicalFile = resolve(root, 'packages/session/session-persistence-jsonl/src/format.ts')
   const physical = program.getSourceFile(physicalFile)?.statements
     .filter((node): node is ts.InterfaceDeclaration | ts.TypeAliasDeclaration =>
@@ -120,7 +116,13 @@ export function extractPersistenceSchema(root: string): PersistenceSchemaInvento
     .filter(declaration => declaration.name.text === 'HeaderLine')
   if (physical?.length !== 1) throw new PersistenceSchemaError('persistence schema: expected one current JSONL HeaderLine declaration')
   const physicalHeader = physical[0] as ts.InterfaceDeclaration | ts.TypeAliasDeclaration
-  validateReachableDeclarations(program, root, [...declarations.values(), physicalHeader])
+  const declarationSources = validateReachableDeclarations(program, root, [...declarations.values(), physicalHeader])
+  const extraction = new SchemaExtractor(root, program, declarationSources)
+  const roots: RootInput[] = [{
+    key: 'SessionHeader',
+    kind: 'header',
+    node: extraction.convert(checker.getTypeFromTypeNode(header.type), header),
+  }]
   roots.push({ key: 'JsonlHeaderLine', kind: 'header', node: extraction.convert(checker.getTypeAtLocation(physicalHeader), physicalHeader) })
   const envelopes: number[] = []
   for (const [index, event] of events.entries()) {
@@ -149,10 +151,15 @@ function hostSourceFiles(root: string, configPath: string, seen = new Set<string
   ]
 }
 
-function validateReachableDeclarations(program: ts.Program, root: string, roots: readonly ts.Node[]): void {
+function validateReachableDeclarations(
+  program: ts.Program,
+  root: string,
+  roots: readonly ts.Node[],
+): ReadonlyMap<ts.Type, readonly ts.Node[]> {
   const checker = program.getTypeChecker()
   const visited = new Set<ts.Node>()
   const declarations = new Map<ts.SourceFile, ts.Node[]>()
+  const definitions = new Map<ts.Type, ts.Node[]>()
   const visit = (node: ts.Node): void => {
     if (visited.has(node)) return
     visited.add(node)
@@ -161,6 +168,13 @@ function validateReachableDeclarations(program: ts.Program, root: string, roots:
       const scopes = declarations.get(file) ?? []
       scopes.push(node)
       declarations.set(file, scopes)
+      if (isNamedTypeDeclaration(node) || ts.isTypeLiteralNode(node)) {
+        // Primitive aliases share checker types with unaliased properties.
+        const type = checker.getTypeAtLocation(node)
+        const entries = definitions.get(type) ?? []
+        entries.push(node)
+        definitions.set(type, entries)
+      }
     }
     const target = ts.isTypeReferenceNode(node) ? node.typeName
       : ts.isExpressionWithTypeArguments(node) ? node.expression
@@ -184,6 +198,7 @@ function validateReachableDeclarations(program: ts.Program, root: string, roots:
       return start !== undefined && scopes.some(scope => start >= scope.getStart() && start < scope.end)
     }))
   if (errors.length > 0) throw new PersistenceSchemaError(errors.map(diagnosticText).join('\n'))
+  return definitions
 }
 
 class SchemaExtractor {
@@ -193,20 +208,24 @@ class SchemaExtractor {
 
   private readonly checker: ts.TypeChecker
 
-  constructor(private readonly root: string, program: ts.Program) {
+  constructor(
+    private readonly root: string,
+    program: ts.Program,
+    private readonly declarationSources: ReadonlyMap<ts.Type, readonly ts.Node[]>,
+  ) {
     this.checker = program.getTypeChecker()
   }
 
   convert(type: ts.Type, site: ts.Node): number {
     const cached = this.cache.get(type)
     if (cached !== undefined) {
-      this.record(cached, type, site)
+      this.record(cached, type)
       return cached
     }
     const id = this.nodes.length
     this.nodes.push({ kind: 'primitive', type: 'never' })
     this.cache.set(type, id)
-    this.record(id, type, site)
+    this.record(id, type)
     const add = (node: SchemaNode): number => { this.nodes[id] = node; return id }
     const flags = type.flags
     if (flags & ts.TypeFlags.Any) {
@@ -371,7 +390,10 @@ class SchemaExtractor {
       const provenance = this.provenance.get(id)
       for (const name of provenance?.names ?? []) item.names.add(name)
       for (const source of provenance?.sources ?? []) item.sources.add(source)
-      if (provenance === undefined || provenance.names.size === 0) for (const path of paths.get(id) ?? []) item.names.add(path)
+      const kind = schema.nodes[0]?.kind
+      if (kind !== 'primitive' && kind !== 'literal' && (provenance === undefined || provenance.names.size === 0)) {
+        for (const path of paths.get(id) ?? []) item.names.add(path)
+      }
       types.set(digest, item)
     }
     return {
@@ -386,18 +408,20 @@ class SchemaExtractor {
     }
   }
 
-  private record(id: number, type: ts.Type, site: ts.Node): void {
+  private record(id: number, type: ts.Type): void {
     const item = this.provenance.get(id) ?? { names: new Set<string>(), sources: new Set<string>() }
-    const symbol = type.aliasSymbol ?? type.getSymbol()
-    if (symbol !== undefined && !symbol.name.startsWith('__')) {
-      for (const declaration of symbol.declarations ?? []) {
-        const file = slash(relative(this.root, declaration.getSourceFile().fileName))
-        if (trackedSource(file)) item.names.add(`${file}#${symbol.name}`)
-      }
-    }
-    const file = slash(relative(this.root, site.getSourceFile().fileName))
-    if (trackedSource(file)) {
-      const position = site.getSourceFile().getLineAndCharacterOfPosition(site.getStart())
+    const declarations = new Set([
+      ...this.declarationSources.get(type) ?? [],
+      ...type.aliasSymbol?.declarations ?? [],
+      ...type.getSymbol()?.declarations ?? [],
+    ])
+    for (const declaration of declarations) {
+      if (!isNamedTypeDeclaration(declaration) && !ts.isTypeLiteralNode(declaration) && !ts.isEnumMember(declaration)) continue
+      const source = declaration.getSourceFile()
+      const file = slash(relative(this.root, source.fileName))
+      if (!trackedSource(file)) continue
+      if (isNamedTypeDeclaration(declaration)) item.names.add(`${file}#${declaration.name.text}`)
+      const position = source.getLineAndCharacterOfPosition(declaration.getStart())
       item.sources.add(`${file}:${String(position.line + 1)}`)
     }
     this.provenance.set(id, item)
@@ -410,6 +434,10 @@ class SchemaExtractor {
   }
 }
 
+function isNamedTypeDeclaration(node: ts.Node): node is ts.TypeAliasDeclaration | ts.InterfaceDeclaration | ts.EnumDeclaration {
+  return ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isEnumDeclaration(node)
+}
+
 function stringLiterals(type: ts.Type, name: string): Set<string> {
   if (type.flags & ts.TypeFlags.Never) return new Set()
   const members = type.isUnion() ? type.types : [type]

+ 13 - 0
scripts/render-persistence-schema.spec.ts

@@ -64,4 +64,17 @@ describe('persistence schema catalog', () => {
     }
     expect(renderPersistenceSchemaDefinitions(inventory)).toContain('| `next` | optional | [`Recursive`](#persistence-type-recursive) |')
   })
+
+  it('uses intrinsic scalar labels even when an alias or reference supplied a name', () => {
+    const schema = canonicalizeSchema([{ kind: 'primitive', type: 'string' }], 0)
+    const digest = schemaDigest(schema)
+    const inventory: PersistenceSchemaInventory = {
+      formatVersion: 1,
+      roots: [],
+      types: [{ digest, schema, names: ['SessionHeader.agentPreset'], sources: [] }],
+    }
+    const rendered = renderPersistenceSchemaDefinitions(inventory)
+    expect(rendered).toContain('### `string`')
+    expect(rendered).not.toContain('### `SessionHeader.agentPreset`')
+  })
 })

+ 42 - 24
scripts/render-persistence-schema.ts

@@ -1,6 +1,7 @@
 /** Readable, linked persistence schemas rendered from the fingerprint inventory. */
 
 import { githubSlug } from './verify-md-links.ts'
+import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
 import {
   canonicalizeSchema,
   schemaChildren,
@@ -58,7 +59,14 @@ function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDispla
     }
     visit(0, root.key)
   }
-  const labels = inventory.types.map(type => ({ type, label: type.names[0] ?? paths.get(type.digest) ?? type.schema.nodes[0]?.kind ?? 'type' }))
+  const labels = inventory.types.map((type) => {
+    const node = nodeAt(type.schema, 0)
+    const label = node.kind === 'primitive' ? node.type
+      : node.kind === 'literal' ? JSON.stringify(node.value)
+        : node.kind === 'opaque' ? node.reason
+          : type.names[0] ?? paths.get(type.digest) ?? node.kind
+    return { type, label }
+  })
   const counts = new Map<string, number>()
   for (const { label } of labels) counts.set(label, (counts.get(label) ?? 0) + 1)
   const used = new Set<string>()
@@ -80,46 +88,55 @@ function reference(digest: string, entries: ReadonlyMap<string, TypeDisplay>): s
   return `[${code(entry.label)}](#${entry.anchor})`
 }
 
-function typeExpression(schema: CanonicalSchema, index: number, entries: ReadonlyMap<string, TypeDisplay>): string {
+function typeExpression(
+  schema: CanonicalSchema,
+  index: number,
+  entries: ReadonlyMap<string, TypeDisplay>,
+  locale: PersistenceCatalogLocale,
+): string {
   const node = nodeAt(schema, index)
   if (node.kind === 'primitive') return code(node.type)
   if (node.kind === 'literal') return code(JSON.stringify(node.value))
-  if (node.kind === 'opaque') return `${code(node.reason)} (opaque)`
+  if (node.kind === 'opaque') return `${code(node.reason)}${persistenceCatalogText[locale].opaque}`
   return reference(schemaDigest(canonicalizeSchema(schema.nodes, index)), entries)
 }
 
-function definition(entry: TypeDisplay, entries: ReadonlyMap<string, TypeDisplay>): string[] {
+function definition(entry: TypeDisplay, entries: ReadonlyMap<string, TypeDisplay>, locale: PersistenceCatalogLocale): string[] {
+  const text = persistenceCatalogText[locale]
   const schema = entry.type.schema
   const node = nodeAt(schema, 0)
   const lines = [`<a id="${entry.anchor}"></a>`, '', `### ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
   if (entry.type.sources.length > 0) {
-    lines.push(`Sources: ${entry.type.sources.map(source => `[${code(source)}](../${sourcePath(source)})`).join(' · ')}`, '')
+    lines.push(`${text.sources}${entry.type.sources.map(source => `[${code(source)}](../${sourcePath(source)})`).join(' · ')}`, '')
   }
-  const expression = (index: number): string => typeExpression(schema, index, entries)
+  const expression = (index: number): string => typeExpression(schema, index, entries, locale)
   switch (node.kind) {
     case 'object':
-      if (node.properties.length === 0 && node.indices.length === 0) lines.push('Empty object.', '')
+      if (node.properties.length === 0 && node.indices.length === 0) lines.push(text.emptyObject, '')
       else {
-        lines.push('| Property | Presence | Type |', '|---|---|---|')
+        lines.push(text.propertyColumns, '|---|---|---|')
         for (const property of node.properties) {
-          lines.push(`| ${code(property.name)} | ${property.optional ? 'optional' : 'required'} | ${expression(property.type)} |`)
+          lines.push(`| ${code(property.name)} | ${property.optional ? text.optional : text.required} | ${expression(property.type)} |`)
         }
-        for (const index of node.indices) lines.push(`| [${expression(index.key)}] | index signature | ${expression(index.value)} |`)
+        for (const index of node.indices) lines.push(`| [${expression(index.key)}] | ${text.index} | ${expression(index.value)} |`)
         lines.push('')
       }
       break
-    case 'array': lines.push(`Array of ${expression(node.element)}.`, ''); break
+    case 'array': lines.push(`${text.arrayPrefix}${expression(node.element)}${text.arraySuffix}`, ''); break
     case 'tuple':
-      lines.push('| Position | Presence | Type |', '|---|---|---|')
-      node.elements.forEach((element, index) => lines.push(`| ${String(index)} | ${element.rest ? 'rest' : element.optional ? 'optional' : 'required'} | ${expression(element.type)} |`))
+      lines.push(text.positionColumns, '|---|---|---|')
+      node.elements.forEach((element, index) => {
+        const presence = element.rest ? text.rest : element.optional ? text.optional : text.required
+        lines.push(`| ${String(index)} | ${presence} | ${expression(element.type)} |`)
+      })
       lines.push('')
       break
     case 'union':
-      lines.push('One of:', '', ...node.types.map(index => `- ${expression(index)}`), '')
+      lines.push(text.oneOf, '', ...node.types.map(index => `- ${expression(index)}`), '')
       break
     case 'primitive': lines.push(code(node.type), ''); break
     case 'literal': lines.push(code(JSON.stringify(node.value)), ''); break
-    case 'opaque': lines.push(`${code(node.reason)}: the declaration does not expose the stored value's internal fields.`, ''); break
+    case 'opaque': lines.push(`${code(node.reason)}${text.opaqueExplanation}`, ''); break
     default: assertNever(node)
   }
   return lines
@@ -128,15 +145,15 @@ function definition(entry: TypeDisplay, entries: ReadonlyMap<string, TypeDisplay
 /**
  * Render every tracked root with its exact digest and resolved type reference.
  * @param inventory - complete current-source schemas and provenance.
+ * @param locale - generated document language.
  * @returns Markdown index including the history and contributor workflow links.
  */
-export function renderPersistenceSchemaIndex(inventory: PersistenceSchemaInventory): string {
+export function renderPersistenceSchemaIndex(inventory: PersistenceSchemaInventory, locale: PersistenceCatalogLocale = 'en'): string {
   const entries = displays(inventory)
+  const text = persistenceCatalogText[locale]
   return [
-    '## Persistence type fingerprints', '',
-    'The [machine inventory](persistence-schema.json) contains every reachable normalized type and its SHA-256 digest. Root digests include referenced types. Comments, source locations, alias names, erased brands, readonly markers, and object property order do not affect these fingerprints. Tuple order, property names, value types, and optionality do.', '',
-    'The [change records](persistence-changes/README.md) acknowledge exact transitions using snapshots kept in this tree. Follow the [review workflow](cookbook/reviewing-persistence-type-changes.md) to classify a change and record it. These checks cover declared type structure; opaque payload contents and behavior without type changes are outside their scope.', '',
-    '| Root | Kind | SHA-256 | Resolved type |', '|---|---|---|---|',
+    `## ${text.fingerprints}`, '', text.fingerprintsIntro, '', text.historyIntro, '',
+    text.rootColumns, '|---|---|---|---|',
     ...inventory.roots.map(root => `| ${code(root.key)} | ${root.kind} | ${code(root.digest)} | ${reference(root.digest, entries)} |`), '',
   ].join('\n')
 }
@@ -144,15 +161,16 @@ export function renderPersistenceSchemaIndex(inventory: PersistenceSchemaInvento
 /**
  * Render every reachable type once, with links for shared and recursive definitions.
  * @param inventory - complete current-source schemas and provenance.
+ * @param locale - generated document language.
  * @returns Markdown definitions whose anchors use names or owning paths instead of hashes.
  */
-export function renderPersistenceSchemaDefinitions(inventory: PersistenceSchemaInventory): string {
+export function renderPersistenceSchemaDefinitions(inventory: PersistenceSchemaInventory, locale: PersistenceCatalogLocale = 'en'): string {
   const entries = displays(inventory)
+  const text = persistenceCatalogText[locale]
   const sorted = [...entries.values()].sort((left, right) => left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0)
   return [
-    '## Resolved persistence types', '',
-    'Each definition appears once. References preserve sharing and recursion; the digest beside a definition includes its complete reachable structure. Source names and locations identify its declarations but are excluded from its digest.', '',
-    ...sorted.flatMap(entry => definition(entry, entries)),
+    `## ${text.definitions}`, '', text.definitionsIntro, '',
+    ...sorted.flatMap(entry => definition(entry, entries, locale)),
   ].join('\n')
 }
 

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor