Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into feat/3220-steer-service

Dudu-0223 3 недель назад
Родитель
Сommit
5b91cdbf8b
100 измененных файлов с 691 добавлено и 397 удалено
  1. 3 3
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml
  2. 30 0
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
  3. 30 0
      .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml
  5. 8 8
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
  6. 8 8
      .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md
  7. 2 2
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml
  8. 4 4
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md
  9. 4 4
      .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md
  10. 2 2
      .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml
  11. 1 1
      .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md
  12. 1 1
      .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md
  13. 6 0
      .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml
  14. 35 0
      .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md
  15. 35 0
      .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
  17. 2 2
      .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
  18. 2 2
      .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
  19. 0 41
      .agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md
  20. 0 41
      .agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md
  21. 1 1
      AGENTS.md
  22. 1 1
      apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts
  23. 5 5
      apps/web/tests/access-confirmation.e2e.ts
  24. 4 4
      apps/web/tests/expected/access-confirmation/ui.expected.md
  25. 2 2
      apps/web/tests/expected/settings-chrome/dialog.expected.md
  26. 9 9
      apps/web/tests/settings-chrome.e2e.ts
  27. 2 2
      docs/deepseek-llm-api-wire-extensions.i18n.yaml
  28. 2 2
      docs/deepseek-llm-api-wire-extensions.md
  29. 2 2
      docs/deepseek-llm-api-wire-extensions.zh.md
  30. 2 2
      docs/event-producer-consumer.i18n.yaml
  31. 5 5
      docs/event-producer-consumer.md
  32. 5 5
      docs/event-producer-consumer.zh.md
  33. 2 2
      docs/persistence-catalog.i18n.yaml
  34. 12 1
      docs/persistence-catalog.md
  35. 12 1
      docs/persistence-catalog.zh.md
  36. 2 2
      docs/subsystems/persistence.i18n.yaml
  37. 2 2
      docs/subsystems/persistence.md
  38. 2 2
      docs/subsystems/persistence.zh.md
  39. 2 2
      docs/subsystems/session.i18n.yaml
  40. 11 0
      docs/subsystems/session.md
  41. 11 0
      docs/subsystems/session.zh.md
  42. 1 0
      packages/api/session-controller/src/types.ts
  43. 2 2
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  44. 1 0
      packages/api/session-controller/tests/transport.host.spec.ts
  45. 9 5
      packages/client/ui-conversation/src/client/locales.ts
  46. 38 20
      packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx
  47. 3 1
      packages/client/ui-conversation/tests/history-transport.perf.client.ts
  48. 36 17
      packages/client/ui-conversation/tests/input-bar.client.spec.tsx
  49. 2 2
      packages/client/ui-permission-presets/README.i18n.yaml
  50. 3 3
      packages/client/ui-permission-presets/README.md
  51. 3 3
      packages/client/ui-permission-presets/README.zh.md
  52. 5 4
      packages/client/ui-permission-presets/src/client/PermissionRow.tsx
  53. 7 1
      packages/client/ui-permission-presets/src/client/index.ts
  54. 18 6
      packages/client/ui-permission-presets/src/client/locales.ts
  55. 32 3
      packages/client/ui-permission-presets/src/client/presentation.ts
  56. 23 5
      packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts
  57. 19 19
      packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx
  58. 2 2
      packages/core/session/README.i18n.yaml
  59. 1 1
      packages/core/session/README.md
  60. 1 1
      packages/core/session/README.zh.md
  61. 3 1
      packages/core/session/src/index.ts
  62. 9 5
      packages/core/session/src/known-event-types.ts
  63. 17 6
      packages/core/session/src/types.ts
  64. 8 0
      packages/core/session/tests/session.spec.ts
  65. 1 0
      packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts
  66. 2 2
      packages/extensions/tool-cordis/src/api-catalog.ts
  67. 2 2
      packages/session/session-persistence-sqlite/README.i18n.yaml
  68. 7 7
      packages/session/session-persistence-sqlite/README.md
  69. 7 7
      packages/session/session-persistence-sqlite/README.zh.md
  70. 1 1
      packages/session/session-persistence-sqlite/resources/sql/insert-event.sql
  71. 1 1
      packages/session/session-persistence-sqlite/resources/sql/schema.sql
  72. 1 1
      packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql
  73. 1 1
      packages/session/session-persistence-sqlite/resources/sql/select-events.sql
  74. 2 2
      packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql
  75. 1 1
      packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql
  76. 0 1
      packages/session/session-persistence-sqlite/resources/sql/set-user-version-19.sql
  77. 1 0
      packages/session/session-persistence-sqlite/resources/sql/set-user-version-20.sql
  78. 6 6
      packages/session/session-persistence-sqlite/src/codec.ts
  79. 7 5
      packages/session/session-persistence-sqlite/src/compression.ts
  80. 1 1
      packages/session/session-persistence-sqlite/src/index.ts
  81. 7 7
      packages/session/session-persistence-sqlite/src/schema.ts
  82. 1 1
      packages/session/session-persistence-sqlite/src/sql.ts
  83. 1 1
      packages/session/session-persistence-sqlite/src/store.ts
  84. 11 10
      packages/session/session-persistence-sqlite/tests/compression.spec.ts
  85. 41 5
      packages/session/session-persistence-sqlite/tests/differential.spec.ts
  86. 3 0
      packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql
  87. 1 1
      packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql
  88. 1 1
      packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql
  89. 2 2
      packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql
  90. 1 1
      packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql
  91. 1 1
      packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql
  92. 1 1
      packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql
  93. 1 0
      packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-20.sql
  94. 16 16
      packages/session/session-persistence-sqlite/tests/sqlite.spec.ts
  95. 2 0
      packages/session/session-persistence-sqlite/tests/test-sql.ts
  96. 2 2
      packages/session/session-persistence/README.i18n.yaml
  97. 1 1
      packages/session/session-persistence/README.md
  98. 1 1
      packages/session/session-persistence/README.zh.md
  99. 16 12
      packages/session/session-persistence/src/coordinator.ts
  100. 24 14
      packages/session/session-persistence/tests/coordinator-contract.ts

+ 3 - 3
.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.i18n.yaml → .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md
-2026-08-25-fail-closed-session-event-vocabulary.md: 537e9a754f7034067d1da31ba2a1bed5bc70cb7e
-2026-08-25-fail-closed-session-event-vocabulary.zh.md: f37bcf34bef3d503aca712d99122e334ff29c258
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md
+2026-08-10-session-log-version-mechanism.md: 82748212b10edf5b201f2be7395cbdb54108fbf7
+2026-08-10-session-log-version-mechanism.zh.md: 3950f41398d032d02a7d6c4487220c6da78388f4

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

@@ -0,0 +1,30 @@
+# Agent Note: Session log versioning — one integer, an upgrade chain, and a per-event ignorable marker
+
+Status: implemented
+
+English | [中文](2026-08-10-session-log-version-mechanism.zh.md)
+
+## Problem
+
+Session logs must be upgradable after release, and the runtime that ships first is the floor for every later decision: whatever refusal and degradation behavior is missing from the first released reader can never be added to the copies users already run. Release issue #1901 required at minimum that an old runtime reading a newer session format reports "unsupported" instead of misreading it. The pre-change reader did the opposite on both axes: `assertVersion` rejected any version mismatch with one direction-blind message, and the JSONL decoder passed unknown event types through untouched, so reconstruction silently skipped them — resuming a gutted session with no diagnostic at all.
+
+## Decision
+
+**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent.
+
+**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers.
+
+**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing.
+
+**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example).
+
+## Consequences
+
+What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, currently `SCHEMA_VERSION` 20), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against. First-party writers do not set `ignorable` through `Session.append`, while a repository-external plugin is a current consumer; its retention and replacement condition lives in the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md). An external informational event carrying the marker remains reloadable, while an unknown required event refuses resume. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating this format version's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first.
+
+## Alternatives considered
+
+- **Major/minor versioning** — the "is it convertible" bit lives on each step's upgrader, and pre-committing it into a number shape invites wrong promises.
+- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption.
+- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked.
+- **Per-plugin runtime registration of known event types** — rejected because it would make the known set composition-dependent and register event names without classifying whether omission is safe. The persisted `ignorable` marker keeps that classification with each record; the [external-plugin retention decision](2026-08-30-retain-ignorable-external-session-events.md) owns the current consumer constraint.

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

@@ -0,0 +1,30 @@
+# Agent Note: Session log 版本机制:单调整数、升级器链、逐事件可忽略标记
+
+Status: implemented
+
+[English](2026-08-10-session-log-version-mechanism.md) | 中文
+
+## 问题
+
+Session log 在发布后必须能升级格式,而最先发布的运行时决定了此后一切的下限:第一个发布版的读取器缺少哪种拒绝和降级行为,用户手里已经装上的副本就永远补不上。发布 issue #1901 的最低要求是老运行时读到新 Session 格式时明确报不支持,而不是读错。改动前的读取器在两个方向上都做反了:`assertVersion` 对任何版本不匹配抛出同一条不区分方向的消息;JSONL 解码器把不认识的事件类型原样放行,重建时静默跳过,恢复出一个内容残缺的会话且没有任何诊断。
+
+## 决定
+
+**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺(设计时很少能预知下一个变更算不算"大")。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。
+
+**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。
+
+**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。
+
+**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。
+
+## 影响
+
+v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,当前为 `SCHEMA_VERSION` 20)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建。第一方写入方不通过 `Session.append` 设置 `ignorable`,但当前有一个仓库外插件依赖该字段;其保留条件与替代机制要求由[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义。带该标记的外部信息性事件可以继续重新加载,未知必需事件则会拒绝恢复。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验本格式版本的 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
+
+## 曾考虑的替代方案
+
+- **大小两级版本号**:能否转换这一位信息属于每一步的升级器,把它预先固化进编号形状会做出错误承诺。
+- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
+- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
+- **插件运行时注册已知事件类型**:不予采用,因为该方案会让已知集依赖插件组合,而且只注册事件名称,无法判定省略事件是否安全。持久化的 `ignorable` 标记把该分类保留在每条记录中;[外部插件保留决策](2026-08-30-retain-ignorable-external-session-events.zh.md)定义当前消费方约束。

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
-2026-08-18-sqlite-physical-chunk-row-compression.md: 34aac2f183d386ffe22f86a6b62fe5e3105b3dfa
-2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 1845185d543f565b55ace6adac973dad5535ad7b
+2026-08-18-sqlite-physical-chunk-row-compression.md: 3324d15abbc87b69a222c74f784fc565e287a38a
+2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 57b252e2f4561f4659e0ed0ad34e0b4b5db68227

+ 8 - 8
.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md

@@ -12,15 +12,15 @@ A physical row that represents several events affects append contiguity, crash r
 
 
 ## Decision
 ## Decision
 
 
-`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-18 implementation. It is the only SQLite persistence package and provider; the predecessor scalar layout and the temporary versioned sibling are not retained. SQLite remains an opt-in switch, while shipped default compositions continue to use JSONL. Both backends implement the same `SessionPersistence` service through `PersistenceCoordinator`, so physical packing changes neither live event delivery nor the logical session API.
+`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-20 implementation. It is the only SQLite persistence package and provider; the predecessor scalar layout and the temporary versioned sibling are not retained. SQLite remains an opt-in switch, while shipped default compositions continue to use JSONL. Both backends implement the same `SessionPersistence` service through `PersistenceCoordinator`, so physical packing changes neither live event delivery nor the logical session API.
 
 
-Schema 18 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows represent one logical event. Packed rows use the storage tags `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`; the SQL `seq` and `time` columns hold the first logical member, and `data` holds the packed payload. Packed rows set `is_packed=1`, while scalar rows set `is_packed=0`; the explicit discriminator prevents a scalar event whose type matches a storage tag from being decoded as packed. The tags are storage vocabulary, not `SessionEventMap` members.
+Schema 20 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows represent one logical event. Packed rows use the storage tags `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`; the SQL `seq` and `time` columns hold the first logical member, and `data` holds the packed payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. The tags are storage vocabulary, not `SessionEventMap` members.
 
 
-SQLite owns chunk encoding and validation inside the schema-18 package. Exact-field whitelisting means unknown fields, surface metadata, incompatible chunk identity, sequence gaps, and unsafe timestamps remain scalar rather than losing information. One packed row represents at most 1,024 events and 1 MiB of uncompressed UTF-8 `data`; the encoder partitions longer runs, and the decoder rejects rows outside those format limits.
+SQLite owns chunk encoding and validation inside the schema-20 package. Exact-field whitelisting means unknown fields, surface metadata, incompatible chunk identity, sequence gaps, and unsafe timestamps remain scalar rather than losing information. One packed row represents at most 1,024 events and 1 MiB of uncompressed UTF-8 `data`; the encoder partitions longer runs, and the decoder rejects rows outside those format limits.
 
 
 The `data` column accepts `TEXT` or `BLOB`. Serialized values below 4 KiB remain text. At or above the threshold, the writer uses Zstandard level 3 and retains the frame only when it is smaller than the text; the reader decompresses the blob before strict UTF-8 decoding and JSON parsing. The fixed moderate level and threshold limit frame overhead and synchronous CPU work while capturing the repeated payloads that dominate retained bytes.
 The `data` column accepts `TEXT` or `BLOB`. Serialized values below 4 KiB remain text. At or above the threshold, the writer uses Zstandard level 3 and retains the frame only when it is smaller than the text; the reader decompresses the blob before strict UTF-8 decoding and JSON parsing. The fixed moderate level and threshold limit frame overhead and synchronous CPU work while capturing the repeated payloads that dominate retained bytes.
 
 
-`source_event_seqs` remains the complete ordered list of earlier events cited by a surface node, including every streamed chunk behind an assembled assistant message. Schema 18 stores the first sequence as an unsigned varint and every subsequent signed difference as a ZigZag varint. This preserves arbitrary order and every sequence while exploiting the overwhelmingly consecutive lists produced by streaming. An empty list is an empty non-null blob, distinct from absent provenance.
+`source_event_seqs` remains the complete ordered list of earlier events cited by a surface node, including every streamed chunk behind an assembled assistant message. Schema 20 stores the first sequence as an unsigned varint and every subsequent signed difference as a ZigZag varint. This preserves arbitrary order and every sequence while exploiting the overwhelmingly consecutive lists produced by streaming. An empty list is an empty non-null blob, distinct from absent provenance.
 
 
 ### Transactional append packing
 ### Transactional append packing
 
 
@@ -32,11 +32,11 @@ Normal append never deletes or replaces an earlier event row. Fixed write-behind
 
 
 Full reads decode each physical row as one all-or-nothing logical span and validate contiguous logical sequences. A reverse pass identifies the last valid `turn/end` without retaining a second decoded copy of the full physical scan; the forward pass decodes one row at a time into the required logical result. A malformed row or gap before that committed boundary is corruption; a malformed final physical row becomes the opaque repair marker at that row's base sequence. Recovery re-reads and validates that marker while holding the write lock, then deletes the whole physical row and any later rows before binding synthetic closers as scalar events. A stale repair cannot delete a newer writer's valid suffix.
 Full reads decode each physical row as one all-or-nothing logical span and validate contiguous logical sequences. A reverse pass identifies the last valid `turn/end` without retaining a second decoded copy of the full physical scan; the forward pass decodes one row at a time into the required logical result. A malformed row or gap before that committed boundary is corruption; a malformed final physical row becomes the opaque repair marker at that row's base sequence. Recovery re-reads and validates that marker while holding the write lock, then deletes the whole physical row and any later rows before binding synthetic closers as scalar events. A stale repair cannot delete a newer writer's valid suffix.
 
 
-`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-18 row span, then reads from the earliest candidate that may contain `fromSeq`. The decoder filters reconstructed members below `fromSeq`, so a suffix may begin inside a packed row without parsing an unrelated earlier scalar row. Reading from that candidate also exposes an overlapping scalar row to contiguity validation instead of letting it hide the packed member. Packed data exceeding the uncompressed format byte limit rejects before JSON parsing.
+`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-20 row span, then reads from the earliest candidate that may contain `fromSeq`. The decoder filters reconstructed members below `fromSeq`, so a suffix may begin inside a packed row without parsing an unrelated earlier scalar row. Reading from that candidate also exposes an overlapping scalar row to contiguity validation instead of letting it hide the packed member. Packed data exceeding the uncompressed format byte limit rejects before JSON parsing.
 
 
 ### Schema ownership
 ### Schema ownership
 
 
-A pristine database initializes at schema 18. Older physical schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; the pre-release package supplies no migration. Every connection disables trusted schemas and memory-mapped I/O before inspecting durable schema, then reads both settings back. After selecting and verifying the journal mode, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. Package code loads every statement and fixed pragma from closed-name `.sql` resources and binds runtime values as parameters.
+A pristine database initializes at schema 20. Older physical schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; the pre-release package supplies no migration. Every connection disables trusted schemas and memory-mapped I/O before inspecting durable schema, then reads both settings back. After selecting and verifying the journal mode, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. Package code loads every statement and fixed pragma from closed-name `.sql` resources and binds runtime values as parameters.
 
 
 ### Physical-write regression
 ### Physical-write regression
 
 
@@ -58,11 +58,11 @@ The repository regression guard writes 1,000 streamed deltas in 40-event durable
 
 
 **Compress every payload.** Rejected because small independent Zstandard frames add headers and synchronous CPU work while losing the cross-record dictionary opportunity of a whole-file stream. On the 105-session comparison corpus, a threshold sweep produced 75.01 MB at 4 KiB, versus 93.87 MB at 16 KiB and 60.92 MB at 1 KiB. The writer fixes level 3 rather than inheriting a library default, matching the moderate level used by [Codex cold-rollout compression](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs) while retaining independent row access.
 **Compress every payload.** Rejected because small independent Zstandard frames add headers and synchronous CPU work while losing the cross-record dictionary opportunity of a whole-file stream. On the 105-session comparison corpus, a threshold sweep produced 75.01 MB at 4 KiB, versus 93.87 MB at 16 KiB and 60.92 MB at 1 KiB. The writer fixes level 3 rather than inheriting a library default, matching the moderate level used by [Codex cold-rollout compression](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs) while retaining independent row access.
 
 
-The final frozen comparison used 105 sessions, 2,507,860 logical events, 512-event durable batches, three independent builds per backend, and three read passes per build. SQLite used 75.01 MB, wrote in 8.58 s, read complete sessions at 3.95/21.58 ms p50/p95, read 50-event tails at 0.253/0.378 ms, and forked every session in 13.10 s. Zstandard JSONL used 30.65 MB and measured 28.21 s, 4.49/23.36 ms, 10.58/80.90 ms, and 14.48 s. The predecessor scalar SQLite layout used 709.57 MB and measured 10.64 s, 9.02/69.16 ms, 0.189/0.293 ms, and 19.30 s. The packed layout is 89.4% smaller than the predecessor, writes 19.4% faster, improves complete-read p50/p95 by 56.2%/68.8%, and reduces 2,507,860 physical event rows to 65,810. Scalar tail-50 and list micro-latency are lower, but the packed provider remains materially faster than JSONL on those paths and wins the dominant size, write, full-read, and fork costs. The 4 KiB threshold is the accepted balance rather than a strict dominance claim. This comparison measured schema 17; schema 18 retains the chunk codec and bounds but changes the row discriminator, so the exact size and timing values remain schema-17 evidence until schema 18 is remeasured.
+The final frozen comparison used 105 sessions, 2,507,860 logical events, 512-event durable batches, three independent builds per backend, and three read passes per build. SQLite used 75.01 MB, wrote in 8.58 s, read complete sessions at 3.95/21.58 ms p50/p95, read 50-event tails at 0.253/0.378 ms, and forked every session in 13.10 s. Zstandard JSONL used 30.65 MB and measured 28.21 s, 4.49/23.36 ms, 10.58/80.90 ms, and 14.48 s. The predecessor scalar SQLite layout used 709.57 MB and measured 10.64 s, 9.02/69.16 ms, 0.189/0.293 ms, and 19.30 s. The packed layout is 89.4% smaller than the predecessor, writes 19.4% faster, improves complete-read p50/p95 by 56.2%/68.8%, and reduces 2,507,860 physical event rows to 65,810. Scalar tail-50 and list micro-latency are lower, but the packed provider remains materially faster than JSONL on those paths and wins the dominant size, write, full-read, and fork costs. The 4 KiB threshold is the accepted balance rather than a strict dominance claim. This comparison measured schema 17; its exact values are evidence for the original packed-row decision, not schema-20 measurements. The [persistence latency and page-size decision](2026-08-25-persistence-latency-and-page-size.md) owns the schema-19 benchmark and current encoding refinements.
 
 
 **Store packed payloads under the logical `assistant/chunk` type.** Rejected because payload heuristics make malformed rows ambiguous and couple physical decoding to future logical payload fields. Explicit tags fail loudly.
 **Store packed payloads under the logical `assistant/chunk` type.** Rejected because payload heuristics make malformed rows ambiguous and couple physical decoding to future logical payload fields. Explicit tags fail loudly.
 
 
-**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 18 because `agentPreset` is a typed core resume invariant shared by JSONL and SQLite, not provider extension metadata. Persisting validated core fields directly keeps both backends aligned; an untyped catch-all would add another compatibility mechanism without a current producer. Revisit this only with a core-owned, namespaced `SessionHeader` extension protocol implemented by every backend.
+**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 20 because `agentPreset` is a typed core resume invariant shared by JSONL and SQLite, not provider extension metadata. Persisting validated core fields directly keeps both backends aligned; an untyped catch-all would add another compatibility mechanism without a current producer. Revisit this only with a core-owned, namespaced `SessionHeader` extension protocol implemented by every backend.
 
 
 **Expose compression rules through configuration or a live registry.** Rejected because same-version databases must be readable independently of runtime topology. The codec is modular source code, but the durable rule set is fixed by schema version.
 **Expose compression rules through configuration or a live registry.** Rejected because same-version databases must be readable independently of runtime topology. The codec is modular source code, but the durable rule set is fixed by schema version.
 
 

+ 8 - 8
.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md

@@ -12,15 +12,15 @@ Status: implemented
 
 
 ## 决策
 ## 决策
 
 
-`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 18 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
+`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 20 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
 
 
-Schema 18 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行设置 `is_packed=1`,标量行设置 `is_packed=0`;显式判别值可防止类型与存储标签同名的标量事件被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。
+Schema 20 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。
 
 
-SQLite 在 schema 18 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
+SQLite 在 schema 20 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
 
 
 `data` 列接受 `TEXT` 或 `BLOB`。序列化值小于 4 KiB 时保持为文本。达到或超过该阈值时,写入方使用 Zstandard level 3,并且只在 frame 小于原文本时保留该 frame;读取方会先解压,再进行严格 UTF-8 解码和 JSON 解析。固定的适中级别与阈值限制 frame 开销与同步 CPU 工作,同时覆盖占据大部分保留字节的重复 payload。
 `data` 列接受 `TEXT` 或 `BLOB`。序列化值小于 4 KiB 时保持为文本。达到或超过该阈值时,写入方使用 Zstandard level 3,并且只在 frame 小于原文本时保留该 frame;读取方会先解压,再进行严格 UTF-8 解码和 JSON 解析。固定的适中级别与阈值限制 frame 开销与同步 CPU 工作,同时覆盖占据大部分保留字节的重复 payload。
 
 
-`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 18 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
+`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 20 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
 
 
 ### 事务化追加打包
 ### 事务化追加打包
 
 
@@ -32,11 +32,11 @@ SQLite 在 schema 18 包内拥有分片编码和验证。字段完全匹配的
 
 
 完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。
 完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。
 
 
-`readFrom(id, fromSeq)` 只检查 schema 18 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
+`readFrom(id, fromSeq)` 只检查 schema 20 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
 
 
 ### Schema 所有权
 ### Schema 所有权
 
 
-全新数据库初始化为 schema 18。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
+全新数据库初始化为 schema 20。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
 
 
 ### 物理写入回归
 ### 物理写入回归
 
 
@@ -58,11 +58,11 @@ SQLite 在 schema 18 包内拥有分片编码和验证。字段完全匹配的
 
 
 **压缩每个 payload。** 不予采用,因为小型独立 Zstandard frame 会增加 header 和同步 CPU 工作,也无法利用整文件流的跨记录字典。在 105 个会话的对比语料上,阈值扫描结果为:4 KiB 生成 75.01 MB,16 KiB 为 93.87 MB,1 KiB 为 60.92 MB。写入方固定使用 level 3,而不是继承库默认值;这与 [Codex 冷 rollout 压缩](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs)所用的适中级别一致,同时保留独立行访问。
 **压缩每个 payload。** 不予采用,因为小型独立 Zstandard frame 会增加 header 和同步 CPU 工作,也无法利用整文件流的跨记录字典。在 105 个会话的对比语料上,阈值扫描结果为:4 KiB 生成 75.01 MB,16 KiB 为 93.87 MB,1 KiB 为 60.92 MB。写入方固定使用 level 3,而不是继承库默认值;这与 [Codex 冷 rollout 压缩](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs)所用的适中级别一致,同时保留独立行访问。
 
 
-最终冻结对比包含 105 个会话、2,507,860 个逻辑事件,以 512 个事件为持久批次;每个后端独立构建三次,每次构建执行三轮读取。SQLite 使用 75.01 MB,写入耗时 8.58 秒,完整读取 p50/p95 为 3.95/21.58 毫秒,读取最后 50 个事件为 0.253/0.378 毫秒,对所有会话执行 fork 为 13.10 秒。Zstandard JSONL 使用 30.65 MB,对应指标为 28.21 秒、4.49/23.36 毫秒、10.58/80.90 毫秒和 14.48 秒。此前的标量 SQLite 布局使用 709.57 MB,对应指标为 10.64 秒、9.02/69.16 毫秒、0.189/0.293 毫秒和 19.30 秒。打包布局比此前布局小 89.4%,写入快 19.4%,完整读取 p50/p95 改善 56.2%/68.8%,并把 2,507,860 个物理事件行减少到 65,810 行。标量布局的最后 50 个事件读取与 list 微延迟更低,但打包提供方在这些路径上仍明显快于 JSONL,并改善主要的空间、写入、完整读取和 fork 成本。4 KiB 阈值是接受的平衡点,而不是严格支配所有指标的结论。该对比测量 schema 17;schema 18 保留分片 codec 与上限,但改变行判别值,因此在重新测量 schema 18 前,精确的大小与时延值仍是 schema 17 证据。
+最终冻结对比包含 105 个会话、2,507,860 个逻辑事件,以 512 个事件为持久批次;每个后端独立构建三次,每次构建执行三轮读取。SQLite 使用 75.01 MB,写入耗时 8.58 秒,完整读取 p50/p95 为 3.95/21.58 毫秒,读取最后 50 个事件为 0.253/0.378 毫秒,对所有会话执行 fork 为 13.10 秒。Zstandard JSONL 使用 30.65 MB,对应指标为 28.21 秒、4.49/23.36 毫秒、10.58/80.90 毫秒和 14.48 秒。此前的标量 SQLite 布局使用 709.57 MB,对应指标为 10.64 秒、9.02/69.16 毫秒、0.189/0.293 毫秒和 19.30 秒。打包布局比此前布局小 89.4%,写入快 19.4%,完整读取 p50/p95 改善 56.2%/68.8%,并把 2,507,860 个物理事件行减少到 65,810 行。标量布局的最后 50 个事件读取与 list 微延迟更低,但打包提供方在这些路径上仍明显快于 JSONL,并改善主要的空间、写入、完整读取和 fork 成本。4 KiB 阈值是接受的平衡点,而不是严格支配所有指标的结论。该对比测量的是 schema 17;其精确数值是原始打包行决策的证据,并非 schema 20 实测。[持久化延迟与 page size 决策](2026-08-25-persistence-latency-and-page-size.zh.md)记录 schema 19 基准与当前编码细节。
 
 
 **把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。
 **把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。
 
 
-**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 18 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
+**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 20 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
 
 
 **通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。
 **通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。
 
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md
-2026-08-25-persistence-latency-and-page-size.md: 27eb58cc551f01c48361a3af3224eb8b12592a00
-2026-08-25-persistence-latency-and-page-size.zh.md: 24ab1835cc313cd617d665a0c52a399d505069ea
+2026-08-25-persistence-latency-and-page-size.md: 3e350ae33655dab82f8c0d7e71b39887e1b6fd34
+2026-08-25-persistence-latency-and-page-size.zh.md: 4bff5ce5e11227594d5cfdce5aebff1b398e6607

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md

@@ -16,7 +16,7 @@ The decision needs evidence from more varied sessions, including long event stre
 
 
 JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. SQLite stores the same arrays as tagged zigzag-delta or `(start, count)` varints, choosing the smaller encoding. Both readers restore the original `number[]` before exposing an event.
 JSONL stores strictly increasing `sourceEventSeqs` as mixed scalar values and inclusive ranges; other orders remain verbatim. SQLite stores the same arrays as tagged zigzag-delta or `(start, count)` varints, choosing the smaller encoding. Both readers restore the original `number[]` before exposing an event.
 
 
-SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 19 and a test pins their SHA-256 digest; replacing them requires another schema-version bump.
+SQLite uses an internal integer `sessions.id` and keeps the public session id once in `sessions.session_key`, so event rows and their primary key do not repeat a text identifier. Each `events.data` value remains independently decodable: the writer tries level-3 Zstandard with the packaged 64 KiB raw-content dictionary and retains SQLite text when compression is not smaller. The dictionary bytes are part of schema 20 and a test pins their SHA-256 digest; replacing them requires another schema-version bump.
 
 
 ### JSONL uses the standard Zstandard level
 ### JSONL uses the standard Zstandard level
 
 
@@ -24,9 +24,9 @@ The JSONL writer keeps one checksummed Zstandard frame per durable append batch
 
 
 ### New SQLite databases use 64 KiB pages
 ### New SQLite databases use 64 KiB pages
 
 
-The SQLite provider sets `page_size=65536` before initializing a pristine schema-19 database. An established schema-19 database retains its current page size because SQLite ignores the pragma after allocation.
+The SQLite provider sets `page_size=65536` before initializing a pristine schema-20 database. An established schema-20 database retains its current page size because SQLite ignores the pragma after allocation.
 
 
-The page size is part of schema 19's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas.
+The page size is part of schema 20's fixed physical layout and is applied through the package's closed SQL resources like the other fixed SQLite pragmas.
 
 
 ### Expanded benchmark
 ### Expanded benchmark
 
 
@@ -62,7 +62,7 @@ An otherwise identical SQLite build isolates the page-size effect: 4 KiB pages u
 
 
 JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. SQLite exchanges approximately 5–26% more time across the measured operations for a 46.8% retained-size reduction; its full write remains materially faster than JSONL, and its suffix read remains much faster. Its complete read and fork are slightly slower than default-level JSONL on this expanded corpus.
 JSONL keeps the low-cost provenance optimization without the level-19 write and fork penalty. SQLite exchanges approximately 5–26% more time across the measured operations for a 46.8% retained-size reduction; its full write remains materially faster than JSONL, and its suffix read remains much faster. Its complete read and fork are slightly slower than default-level JSONL on this expanded corpus.
 
 
-New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 19 rejects every other schema version rather than migrating it.
+New SQLite databases use 64 KiB WAL frames and cache pages. Small databases may reserve more bytes for sparsely populated schema and metadata pages, while the measured multi-session workload gains substantially better `events` page utilization. Schema 20 rejects every other schema version rather than migrating it.
 
 
 ## Related
 ## Related
 
 

+ 4 - 4
.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 
 JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。SQLite 把同一数组存为带 tag 的 zigzag-delta 或 `(start, count)` varint,并选择更小的编码。两个读取方都会在暴露事件前还原原始 `number[]`。
 JSONL 把严格递增的 `sourceEventSeqs` 存为标量值与闭区间的混合数组,其他顺序保持原样。SQLite 把同一数组存为带 tag 的 zigzag-delta 或 `(start, count)` varint,并选择更小的编码。两个读取方都会在暴露事件前还原原始 `number[]`。
 
 
-SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 19,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。
+SQLite 使用内部整数 `sessions.id`,并只在 `sessions.session_key` 中保留一次公开会话 id,使事件行及其主键不再重复文本标识。每个 `events.data` 值仍可独立解码:写入方尝试用打包的 64 KiB raw-content 字典执行 level-3 Zstandard 压缩,结果不更小时保留 SQLite 文本。字典字节属于 schema 20,测试固定其 SHA-256 摘要;替换字典需要再次提升 schema 版本。
 
 
 ### JSONL 使用 Zstandard 标准级别
 ### JSONL 使用 Zstandard 标准级别
 
 
@@ -24,9 +24,9 @@ JSONL 写入方继续为每个持久 append 批次写入一个带 checksum 的 Z
 
 
 ### 新建 SQLite 数据库使用 64 KiB page
 ### 新建 SQLite 数据库使用 64 KiB page
 
 
-SQLite 提供方在初始化全新 schema-19 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-19 数据库保留其当前 page size。
+SQLite 提供方在初始化全新 schema-20 数据库前设置 `page_size=65536`。SQLite 在 page 已分配后会忽略该 pragma,因此已有 schema-20 数据库保留其当前 page size。
 
 
-Page size 属于 schema 19 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。
+Page size 属于 schema 20 的固定物理布局,并与其他固定 SQLite pragma 一样通过包内封闭的 SQL 资源应用。
 
 
 ### 扩展基准
 ### 扩展基准
 
 
@@ -62,7 +62,7 @@ Page size 属于 schema 19 的固定物理布局,并与其他固定 SQLite pra
 
 
 JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。SQLite 以实测各项操作约 5–26% 的额外耗时换取 46.8% 的保留体积缩减;其完整写入仍明显快于 JSONL,后缀读取也仍快得多。在这份扩展语料上,完整读取与 fork 略慢于默认级别 JSONL。
 JSONL 保留低成本来源优化,同时避开 level-19 的写入与 fork 代价。SQLite 以实测各项操作约 5–26% 的额外耗时换取 46.8% 的保留体积缩减;其完整写入仍明显快于 JSONL,后缀读取也仍快得多。在这份扩展语料上,完整读取与 fork 略慢于默认级别 JSONL。
 
 
-新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 19 会拒绝其他所有 schema 版本,而不是迁移它们。
+新建 SQLite 数据库使用 64 KiB WAL frame 与 cache page。小型数据库可能为稀疏的 schema 与元数据 page 预留更多字节,而实测的多会话工作负载显著改善了 `events` page 利用率。Schema 20 会拒绝其他所有 schema 版本,而不是迁移它们。
 
 
 ## 相关资料
 ## 相关资料
 
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md
-2026-08-25-rename-code-mode-to-ptc.md: 9f53b9b5d8c3581d5c2dfe0174ad1c279cf47ba3
-2026-08-25-rename-code-mode-to-ptc.zh.md: 56a9e5ec3ca660fd36d21f9c4dbcb1d5cbd5fbf9
+2026-08-25-rename-code-mode-to-ptc.md: 618167516aefc54445d37cb1ce3939419e707bf5
+2026-08-25-rename-code-mode-to-ptc.zh.md: d6cf5cdea1154bd2b8cb424653b76315bb20b05d

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md

@@ -34,4 +34,4 @@ Kept unchanged: `run_code` and its `code` parameter (they name the program paylo
 
 
 ## Consequences
 ## Consequences
 
 
-Configs with `mode: code` and preset ids `code` are unsupported on this build. The session-persistent vocabulary still says `tool/code-dispatch*`, `tools-code-mode`, and `:code:`, so existing session logs load unchanged and no `SESSION_FORMAT_VERSION` bump is needed yet. The stacked persistence PR renames that vocabulary and is blocked until the v0→v1 migration lands with it (the version mechanics are the [session-event-vocabulary note](../simplification/2026-08-25-fail-closed-session-event-vocabulary.md)). Keyless snapshot refreshes carry this PR's vocabulary; the persistence PR refreshes the dispatch-bearing fixtures. The shipped decision this note renames is [the PTC foundation note](../feature/2026-06-15-ptc.md).
+Configs with `mode: code` and preset ids `code` are unsupported on this build. The session-persistent vocabulary still says `tool/code-dispatch*`, `tools-code-mode`, and `:code:`, so existing session logs load unchanged and no `SESSION_FORMAT_VERSION` bump is needed yet. The stacked persistence PR renames that vocabulary and is blocked until the v0→v1 migration lands with it (the version mechanics are in the [session-log versioning note](2026-08-10-session-log-version-mechanism.md)). Keyless snapshot refreshes carry this PR's vocabulary; the persistence PR refreshes the dispatch-bearing fixtures. The shipped decision this note renames is [the PTC foundation note](../feature/2026-06-15-ptc.md).

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md

@@ -34,4 +34,4 @@ Status: implemented
 
 
 ## 后果
 ## 后果
 
 
-配置中写 `mode: code`、预设 id 为 `code`,在本构建上不再受支持。会话持久词汇仍为 `tool/code-dispatch*`、`tools-code-mode` 与 `:code:`,因此既有会话日志照常读取,无需 `SESSION_FORMAT_VERSION` 提升。堆叠的持久化 PR 负责重命名该词汇,并被阻塞到 v0→v1 迁移与其一同落地(版本机制见 [session event 词汇 Note](../simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md))。无密钥的 snapshot refresh 携带本 PR 的词汇;持久化 PR 刷新包含分发的夹具。本 Note 所更名的已发布决策是 [PTC 基础 Note](../feature/2026-06-15-ptc.zh.md)。
+配置中写 `mode: code`、预设 id 为 `code`,在本构建上不再受支持。会话持久词汇仍为 `tool/code-dispatch*`、`tools-code-mode` 与 `:code:`,因此既有会话日志照常读取,无需 `SESSION_FORMAT_VERSION` 提升。堆叠的持久化 PR 负责重命名该词汇,并被阻塞到 v0→v1 迁移与其一同落地(版本机制见 [Session log 版本 Note](2026-08-10-session-log-version-mechanism.zh.md))。无密钥的 snapshot refresh 携带本 PR 的词汇;持久化 PR 刷新包含分发的夹具。本 Note 所更名的已发布决策是 [PTC 基础 Note](../feature/2026-06-15-ptc.zh.md)。

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md
+2026-08-30-retain-ignorable-external-session-events.md: 8217f1865f13b695bbd7095b2f7741b065eb5a08
+2026-08-30-retain-ignorable-external-session-events.zh.md: 4f988cf28b40c09d86e23c896da645018e918993

+ 35 - 0
.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md

@@ -0,0 +1,35 @@
+# Agent Note: Retain ignorable session events for external plugins
+
+Status: implemented
+
+English | [中文](2026-08-30-retain-ignorable-external-session-events.zh.md)
+
+## Problem
+
+The session event envelope carries `ignorable?: true` so a reader can accept an unrecognized informational event without treating every vocabulary addition as a new session format. [PR #3087](https://github.com/deepseek-harness/deepseek-harness/pull/3087) removed the field after finding no first-party producer and made every unknown event required-on-read.
+
+That producer inventory did not cover a third-party plugin that currently depends on the field. Without `ignorable`, a first-party reader rejects a stored session containing the plugin's informational event because the event is outside the repository-generated `KNOWN_SESSION_EVENT_TYPES`. The plugin has no replacement registration or versioning mechanism, so deleting the field before a replacement exists breaks a current external consumer.
+
+## Decision
+
+The canonical `SessionEvent` envelope retains `ignorable?: true`, and every representation preserves it: seed validation, JSONL, SQLite, API transport, generated catalogs, and test fixtures. `PersistenceCoordinator` continues to refuse an unknown event unless its stored envelope explicitly carries `ignorable: true`; absent remains required-on-read.
+
+SQLite schema 20 stores packed physical rows with `ignorable=0`, scalar events marked `ignorable: true` with `ignorable=1`, and other scalar events with `NULL`. This keeps the logical marker and the packed-row discriminator in the same representation without confusing a scalar event whose name matches a physical chunk tag.
+
+The field is removable only after a replacement supports the current third-party plugin across event production, persistence, reload, and transport, with an explicit cutover for sessions already containing the marker. The [session log versioning decision](2026-08-10-session-log-version-mechanism.md) continues to own the default-required safety rule and format-version policy.
+
+## Alternatives considered
+
+**Require every unknown event on read.** Rejected because the current third-party plugin emits an informational event outside the repository-generated vocabulary. A first-party reload would reject that session even though omitting the event is safe.
+
+**Delete the field and design a replacement later.** Rejected because that ordering creates an immediate compatibility gap with no migration or cutover path for the plugin or its stored sessions.
+
+**Treat every repository-external event as ignorable.** Rejected because a reader cannot infer that an unknown durable event is informational. An external event may change later reconstruction or plugin-owned state.
+
+**Register mounted plugin event names as known.** Not adopted as the removal mechanism because event-name registration alone does not classify whether absence is safe, and acceptance would depend on the reader's current composition rather than the stored record.
+
+## Consequences
+
+Third-party informational events can remain reloadable when their stored records carry the explicit marker, while unknown required events still fail loudly. The field remains part of the public event envelope, persistence schemas, transport types, generated references, and their tests until a replacement satisfies the cutover condition.
+
+SQLite advances from schema 19 to schema 20 because restoring the durable column changes the pre-release physical database format. The provider continues to reject other schema versions rather than migrating them.

+ 35 - 0
.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md

@@ -0,0 +1,35 @@
+# Agent Note: 为外部插件保留可忽略会话事件
+
+Status: implemented
+
+[English](2026-08-30-retain-ignorable-external-session-events.md) | 中文
+
+## 问题
+
+会话事件信封包含 `ignorable?: true`,读取器因此可以接受不认识的信息性事件,而不必把每次词汇增加都视为新的会话格式。[PR #3087](https://github.com/deepseek-harness/deepseek-harness/pull/3087) 在没有发现第一方生产方后删除了该字段,并把每个未知事件都改为读取必需项。
+
+该生产方清单没有覆盖当前依赖此字段的一个第三方插件。没有 `ignorable` 时,第一方读取器会拒绝包含该插件信息性事件的已存会话,因为该事件不在仓库生成的 `KNOWN_SESSION_EVENT_TYPES` 中。插件没有可替代的注册或版本机制,因此在替代机制存在前删除该字段会破坏当前外部消费方。
+
+## 决定
+
+标准 `SessionEvent` 信封保留 `ignorable?: true`,每种表示都保留它:seed 校验、JSONL、SQLite、API 传输、生成目录与测试 fixture。`PersistenceCoordinator` 继续拒绝未知事件,除非已存信封显式带有 `ignorable: true`;字段不存在时仍表示读取必需。
+
+SQLite schema 20 对打包物理行存储 `ignorable=0`,对带 `ignorable: true` 的标量事件存储 `ignorable=1`,对其他标量事件存储 `NULL`。这样,逻辑标记与打包行判别值可以共用一种表示,同时不会把名称与物理分片标签相同的标量事件混淆为打包行。
+
+只有替代机制在事件生产、持久化、重新加载与传输中都支持当前第三方插件,并为已包含该标记的会话提供显式切换方案后,才能删除此字段。[Session log 版本决策](2026-08-10-session-log-version-mechanism.zh.md)继续定义默认读取必需的安全规则与格式版本策略。
+
+## 曾考虑的替代方案
+
+**要求读取所有未知事件。** 不予采用,因为当前第三方插件会发出仓库生成词汇之外的信息性事件。即使省略该事件是安全的,第一方重新加载仍会拒绝该会话。
+
+**先删除字段,以后再设计替代机制。** 不予采用,因为该顺序会立刻产生兼容缺口,而且插件及其已存会话都没有迁移或切换路径。
+
+**把所有仓库外事件都视为可忽略。** 不予采用,因为读取器无法推断未知持久事件是否属于信息性事件。外部事件可能改变后续重建或插件自有状态。
+
+**把已挂载插件的事件名称注册为已知。** 不作为删除机制采用,因为只注册事件名称无法判定缺失该事件是否安全,而且接受结果会依赖读取器的当前组合,而不是已存记录。
+
+## 影响
+
+第三方信息性事件的已存记录带有显式标记时可以继续重新加载,未知必需事件则仍会明确失败。在替代机制满足切换条件前,该字段继续属于公开事件信封、持久化 schema、传输类型、生成引用及其测试。
+
+恢复持久列改变了预发布物理数据库格式,因此 SQLite 从 schema 19 提升到 schema 20。提供方继续拒绝其他 schema 版本,而不是迁移它们。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
-2026-07-31-gui-full-access-confirmation.md: f63502cd3e2306f36b136e6ed8543641449c3d83
-2026-07-31-gui-full-access-confirmation.zh.md: f4b3686d1e1ad9e51a08e513a7dd5930d311582d
+2026-07-31-gui-full-access-confirmation.md: c0ae295c312e390b47395bdd09da4317e8ff6c81
+2026-07-31-gui-full-access-confirmation.zh.md: 1679fc5060a5f175e61383d31d76652556227de4

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md

@@ -10,13 +10,13 @@ Switching the web client to `danger-full-access` was a single click on a permiss
 
 
 ## Decision
 ## Decision
 
 
-**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
+**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under its locale-owned product label; every dismissal path submits nothing.**
 
 
 - `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
 - `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
 - The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
 - The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
 - The `/permission` popup (ui-permission over the ui-commands shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
 - The `/permission` popup (ui-permission over the ui-commands shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
 - The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
 - The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
-- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
+- Canonical built-in preset names render through each picker's locale dictionary (`Full access` in English and `完全权限` in Chinese), while explicit host labels remain unchanged. Command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md

@@ -10,13 +10,13 @@ Status: implemented
 
 
 ## 决策
 ## 决策
 
 
-**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
+**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以 locale 所有的产品标签展示;所有取消路径都不作任何提交。**
 
 
 - `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` slot,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
 - `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` slot,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
 - composer chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot 以 `access.confirm.*` 键供给。
 - composer chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale slot 以 `access.confirm.*` 键供给。
 - `/permission` popup(ui-permission 构建于 ui-commands 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
 - `/permission` popup(ui-permission 构建于 ui-commands 外壳之上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
 - 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
 - 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
-- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
+- 规范内置预设名通过每个选择器的 locale 词典呈现(英文为 `Full access`,中文为「完全权限」),显式 host 标签保持原样。命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
 
 
 ## 考虑过的替代方案
 ## 考虑过的替代方案
 
 

+ 0 - 41
.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md

@@ -1,41 +0,0 @@
-# Agent Note: Require known session event types on read
-
-Status: implemented
-
-English | [中文](2026-08-25-fail-closed-session-event-vocabulary.zh.md)
-
-## Problem
-
-A session reader must not silently omit a durable event it does not understand. An unknown event can change later request reconstruction, policy state, recovery, or another plugin-owned projection, so successful JSON parsing is not enough to establish a faithful read. The reader before [issue #1901](https://github.com/deepseek-ai/deepseek-harness/issues/1901) passed unknown event types through while core folds ignored them, allowing a resumed session to lose semantics without a diagnostic.
-
-The first refusal mechanism combined a generated known-event set with an optional per-record `ignorable: true` assertion intended for informational event additions. No production writer used the assertion, and `Session.append()` did not expose a way to set it. Event types added after the mechanism remained required-on-read. The unused field nevertheless expanded the canonical event type, seed validation, persistence formats, SQLite schema, session transport, DeepSeek request extension, generated catalogs, documentation, and tests.
-
-## Decision
-
-Every session event type is required-on-read. After supported legacy records are normalized, `PersistenceCoordinator` compares each event type with `KNOWN_SESSION_EVENT_TYPES`, the generated set of every `SessionEventMap` member declared in this repository. Any unknown type refuses reconstruction with `SessionFormatUnsupportedError`; the diagnostic names the event and sequence, identifies the likely newer writer, and includes the raw artifact path when the backend has one. The guard remains read-side only because rejecting an append after a live event is committed would interrupt durability before the session can report the unsupported log on its next load.
-
-`SessionEvent` has no optional unknown-event skip field. JSONL continues to serialize the same event objects because no production append path emitted that field, and `SESSION_FORMAT_VERSION` remains `0`. The SQLite provider replaces the overloaded `ignorable` column with the schema-18 `is_packed` discriminator: scalar logical events store `0`, packed chunk rows store `1`, and an event name equal to a physical chunk tag remains unambiguous before the coordinator applies the known-type guard.
-
-`SESSION_FORMAT_VERSION` remains one monotonic integer. A writer bumps it when an older runtime cannot interpret a structural or semantic change with full correctness: session header fields, event envelope fields, core event semantics, or the `SurfaceEventType`/`SurfaceOp` mechanism. Adding an event type alone does not require a bump because an older reader refuses that exact unknown type instead of misreading the log. Equal versions read normally; unequal versions currently refuse with a directional diagnostic. The n→n+1 upgrader chain remains deferred until a real v0→v1 step provides an input and output to test. A future view upgrade belongs in memory, with durable replacement only when the user continues the session; a missing step leaves the source artifact available for raw viewing.
-
-Repository-external `SessionEventMap` members remain outside the generated set. They can run and persist during the live process, but a first-party persistence reader refuses them on reload until a real external-event consumer justifies a registration mechanism. This preserves the existing loud pre-release limitation without a composition-dependent known set.
-
-## Alternatives considered
-
-**Keep the per-record skip assertion.** Rejected because it has no production producer, is not expressible through `Session.append()`, and requires every storage and transport representation to preserve a speculative choice. A real need should first define which event type is safe to omit, then make the append implementation emit that classification consistently instead of relying on each call site.
-
-**Ignore every unknown event.** Rejected because a reader cannot infer that an unknown durable fact is informational. Silent omission can resume a session with incorrect model input or plugin state.
-
-**Bump the session format for every new event type.** Rejected because the generated type guard already makes older readers fail safely at the exact unsupported record, while newer readers continue to accept older logs. The format integer remains reserved for changes that alter how known records must be interpreted.
-
-**Register known event names from mounted plugins.** Rejected without a current external consumer because the same build would accept or reject one stored log according to runtime composition. A future registration design must distinguish required plugin state from genuinely optional records and preserve that distinction on disk.
-
-**Use major/minor versions or rewrite on view.** Rejected because upgrade availability is a property of each version step, not a promise encoded by two counters, and opening a session must not destructively rewrite its only artifact. A converter defect must not turn browsing into data loss or make an older runtime lose access merely because a newer one viewed the log.
-
-## Consequences
-
-An older build cannot resume a newer same-version log once that log contains any event type it does not know, even when the new event is informational. This is a deliberate loss of unused forward-degradation behavior in exchange for one event envelope and one failure rule. If a real producer later requires older readers to continue around an optional event, the design must classify the event type once, make `Session.append()` emit the persisted classification automatically, and cover both persistence backends and the wire representation.
-
-First-party JSONL session bytes remain unchanged, including packed rows and `SESSION_FORMAT_VERSION = 0`. Existing first-party JSONL sessions remain readable. SQLite is opt-in and follows the pre-release schema policy: schema 18 has no migration from schema 17, and incompatible databases refuse rather than being rewritten. The [SQLite physical compression decision](../architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) owns that backend's packed-row representation.
-
-The assembled headless refusal test proves that a user sees the unknown type, sequence, newer-writer direction, and raw JSONL path. Core seed tests reject fields outside the current event envelope; persistence contract tests reject every unknown type; SQLite codec and differential tests cover scalar and packed discrimination, suffix reads, repair, and cross-backend logical equality. The generated persistence catalog and known-event module keep the reader's set synchronized with repository-owned declarations.

+ 0 - 41
.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md

@@ -1,41 +0,0 @@
-# Agent Note: 读取时要求会话事件类型已知
-
-Status: implemented
-
-[English](2026-08-25-fail-closed-session-event-vocabulary.md) | 中文
-
-## 问题
-
-会话读取器不得静默省略自己无法理解的持久事件。未知事件可能改变后续请求重建、策略状态、恢复或其他插件所有的投影,因此 JSON 解析成功不足以证明读取保真。[问题 #1901](https://github.com/deepseek-ai/deepseek-harness/issues/1901) 之前的读取器会放行未知事件类型,而核心折叠会忽略它们,使恢复的会话可能在没有诊断的情况下丢失语义。
-
-最初的拒绝机制将生成的已知事件集合与可选的逐记录 `ignorable: true` 声明结合,该声明原本用于信息性新增事件。没有任何生产写入方使用该声明,`Session.append()` 也没有暴露设置方式。机制落地后新增的事件类型仍然都是读取必需项。但这个未使用字段仍然扩大了权威事件类型、seed 校验、持久化格式、SQLite schema、会话传输、DeepSeek 请求扩展、生成目录、文档与测试。
-
-## 决策
-
-每个会话事件类型都是读取必需项。受支持的 legacy 记录归一化后,`PersistenceCoordinator` 会将每个事件类型与 `KNOWN_SESSION_EVENT_TYPES` 比较;后者是从本仓库声明的所有 `SessionEventMap` 成员生成的集合。任何未知类型都以 `SessionFormatUnsupportedError` 拒绝重建;诊断会列出事件与序号,指明日志可能由更新的写入方生成,并在后端拥有独立原始产物时附上该路径。该守卫仍只在读取侧生效,因为在实时事件已提交后拒绝追加会中断持久化,使会话无法在下次加载时报告不受支持的日志。
-
-`SessionEvent` 没有可选的未知事件跳过字段。JSONL 继续序列化相同的事件对象,因为生产追加路径从未发出该字段,`SESSION_FORMAT_VERSION` 仍为 `0`。SQLite 提供方将被复用的 `ignorable` 列替换为 schema 18 的 `is_packed` 判别值:标量逻辑事件存储 `0`,打包分片行存储 `1`,与物理分片标签同名的事件在协调器应用已知类型守卫之前仍可明确解码。
-
-`SESSION_FORMAT_VERSION` 仍是单个单调整数。当较旧运行时无法完全正确地解释某项结构或语义变更时,写入方必须升版本:会话 header 字段、事件 envelope 字段、核心事件语义或 `SurfaceEventType`/`SurfaceOp` 机制。仅新增事件类型无需升版本,因为较旧读取器会拒绝该确切的未知类型,而不是误读日志。版本相等时正常读取;版本不等时当前以分方向诊断拒绝。n→n+1 升级器链仍推迟到第一个真实 v0→v1 步骤提供可测的输入和输出时建立。未来的查看升级属于内存转换,只有用户继续会话时才持久替换;缺失的步骤会保留源产物以供原始查看。
-
-仓库外的 `SessionEventMap` 成员仍不在生成集合内。它们可在实时进程中运行并持久化,但第一方持久化读取器在重新加载时会拒绝它们,直到真实的外部事件消费方证明需要注册机制。这保留了现有的预发布显式限制,同时避免已知集合依赖运行时组合。
-
-## 考虑过的替代方案
-
-**保留逐记录跳过声明。**不予采用,因为它没有生产使用方,无法通过 `Session.append()` 表达,并且要求每种存储与传输表示都保留一项推测性选择。真实需求应先定义可安全省略的事件类型,再让追加实现统一发出该分类,而不是依赖每个调用点。
-
-**忽略每个未知事件。**不予采用,因为读取器无法推断一项未知持久事实是否仅用于信息。静默省略可能使会话以错误的模型输入或插件状态恢复。
-
-**为每个新事件类型升级会话格式。**不予采用,因为生成的类型守卫已使较旧读取器在确切的不受支持记录处安全失败,而较新读取器仍可接受较旧日志。格式整数仍保留给会改变已知记录解读方式的变更。
-
-**从已挂载插件注册已知事件名称。**在没有当前外部消费方时不予采用,因为同一构建会根据运行时组合接受或拒绝同一份存储日志。未来的注册设计必须区分必需插件状态与真正可选的记录,并将该区分持久保存。
-
-**使用主版本/次版本或在查看时改写。**不予采用,因为升级可用性是每个版本步骤的属性,不是两个计数器编码的承诺;打开会话也不得破坏性地改写其唯一产物。转换器缺陷不得让浏览变成数据丢失,也不得仅因较新运行时查看过日志就使较旧运行时失去访问权。
-
-## 后果
-
-较旧构建在较新的同版本日志包含任何未知事件类型后都无法恢复该日志,即使新事件仅用于信息。这是对未使用的前向降级行为的有意放弃,换取单一事件 envelope 与单一失败规则。如果真实生产方以后需要较旧读取器跳过可选事件并继续会话,设计必须只对事件类型分类一次,让 `Session.append()` 自动发出持久分类,并覆盖两个持久化后端和线上表示。
-
-第一方 JSONL 会话字节保持不变,包括打包行与 `SESSION_FORMAT_VERSION = 0`。现有第一方 JSONL 会话仍可读。SQLite 是可选功能,并遵循预发布 schema 策略:schema 18 不从 schema 17 迁移,不兼容数据库会被拒绝而不是改写。[SQLite 物理压缩决策](../architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)拥有该后端的打包行表示。
-
-组装后的 headless 拒绝测试证明用户会看到未知类型、序号、更新写入方方向与原始 JSONL 路径。核心 seed 测试拒绝当前事件 envelope 以外的字段;持久化约定测试拒绝每个未知类型;SQLite codec 与差分测试覆盖标量与打包判别、后缀读取、修复与跨后端逻辑相等。生成的持久化目录与已知事件模块使读取器集合与仓库所有的声明保持同步。

+ 1 - 1
AGENTS.md

@@ -105,7 +105,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`,
 - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
 - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it.
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
 - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer.
 - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)).
 - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)).
-- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. Every `SessionEventMap` member is required-on-read: builds that do not know its type refuse the log; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md)).
+- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read by default — builds that do not know a type refuse the log unless the event carries the envelope's `ignorable: true`; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)).
 - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
 - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default.
 - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
 - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)).
 - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.
 - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event.

+ 1 - 1
apps/cli/tests/profiles/headless/tests/session-format-guard.expected.e2e.ts

@@ -97,7 +97,7 @@ describe('session format guard through the assembled app', () => {
       },
       },
     })
     })
     expect(result.stderr).toContain(
     expect(result.stderr).toContain(
-      `session "${sessionId}" contains event type "future/event" (seq 2) unknown to this harness; refusing to interpret the log — it was likely written by a newer harness`,
+      `session "${sessionId}" contains event type "future/event" (seq 2) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`,
     )
     )
     // macOS reports the temp dir via the /private symlink parent; assert the
     // macOS reports the temp dir via the /private symlink parent; assert the
     // stable path suffix instead of the realpath-dependent prefix.
     // stable path suffix instead of the realpath-dependent prefix.

+ 5 - 5
apps/web/tests/access-confirmation.e2e.ts

@@ -50,13 +50,13 @@ describe('web e2e: Full access confirmation', () => {
     const access = page.locator('button[aria-label^="访问模式"]').first()
     const access = page.locator('button[aria-label^="访问模式"]').first()
     await access.waitFor({ timeout: 10_000 })
     await access.waitFor({ timeout: 10_000 })
 
 
-    expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:Workspace Write')
+    expect(await access.getAttribute('aria-label')).toBe('访问模式,当前:可写入工作区')
 
 
     await access.click()
     await access.click()
-    await page.getByRole('menuitem', { name: 'Full access' }).click()
-    const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
+    await page.getByRole('menuitem', { name: '完全权限' }).click()
+    const dialog = page.getByRole('dialog', { name: '确认启用完全权限?' })
     await dialog.waitFor({ timeout: 10_000 })
     await dialog.waitFor({ timeout: 10_000 })
-    const enable = dialog.getByRole('button', { name: '启用 Full access' })
+    const enable = dialog.getByRole('button', { name: '启用完全权限' })
     expect(await enable.isDisabled()).toBe(true)
     expect(await enable.isDisabled()).toBe(true)
 
 
     // The modal is in this page's body (not a native/new window) and escapes
     // The modal is in this page's body (not a native/new window) and escapes
@@ -69,7 +69,7 @@ describe('web e2e: Full access confirmation', () => {
     expect(await enable.isEnabled()).toBe(true)
     expect(await enable.isEnabled()).toBe(true)
     await enable.click()
     await enable.click()
     await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
     await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
-      .toBe('访问模式,当前:Full access')
+      .toBe('访问模式,当前:完全权限')
     expect(await dialog.count()).toBe(0)
     expect(await dialog.count()).toBe(0)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
   }, 60_000)

+ 4 - 4
apps/web/tests/expected/access-confirmation/ui.expected.md

@@ -1,10 +1,10 @@
-- dialog "确认启用 Full access?":
-  - heading "确认启用 Full access?" [level=2]
+- dialog "确认启用完全权限?":
+  - heading "确认启用完全权限?" [level=2]
   - button "关闭":
   - button "关闭":
     - img
     - img
   - img
   - img
-  - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
+  - paragraph: 启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
   - checkbox "我已了解风险,并愿意继续"
   - checkbox "我已了解风险,并愿意继续"
   - text: 我已了解风险,并愿意继续
   - text: 我已了解风险,并愿意继续
   - button "取消"
   - button "取消"
-  - button "启用 Full access" [disabled]
+  - button "启用完全权限" [disabled]

+ 2 - 2
apps/web/tests/expected/settings-chrome/dialog.expected.md

@@ -18,8 +18,8 @@
     - img
     - img
     - text: 关闭
     - text: 关闭
   - text: 权限 选择新会话的默认权限模式
   - text: 权限 选择新会话的默认权限模式
-  - button "Workspace Write":
-    - text: Workspace Write
+  - button "可写入工作区":
+    - text: 可写入工作区
     - img
     - img
   - text: 语言
   - text: 语言
   - button "中文":
   - button "中文":

+ 9 - 9
apps/web/tests/settings-chrome.e2e.ts

@@ -62,7 +62,7 @@ describe('web e2e: settings modal and General preferences', () => {
     expect(await trigger.getAttribute('aria-expanded')).toBe('true')
     expect(await trigger.getAttribute('aria-expanded')).toBe('true')
     // General is active by default; Permission, Language and Appearance are functional.
     // General is active by default; Permission, Language and Appearance are functional.
     expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
     expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
-    await dialog.getByRole('button', { name: 'Workspace Write' }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '可写入工作区' }).waitFor({ timeout: 10_000 })
     await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
     await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
     await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
     await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
     const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
     const openDocument = dialog.getByRole('button', { name: '打开配置文件' })
@@ -150,12 +150,12 @@ describe('web e2e: settings modal and General preferences', () => {
     await page.getByRole('button', { name: '设置', exact: true }).click()
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const dialog = page.getByRole('dialog', { name: '设置' })
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.waitFor({ timeout: 10_000 })
     await dialog.waitFor({ timeout: 10_000 })
-    const selector = dialog.getByRole('button', { name: 'Workspace Write' })
+    const selector = dialog.getByRole('button', { name: '可写入工作区' })
     await selector.waitFor({ timeout: 10_000 })
     await selector.waitFor({ timeout: 10_000 })
     await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
     await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
     await selector.click()
     await selector.click()
-    await page.getByRole('menuitem', { name: 'Read Only' }).click()
-    await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
+    await page.getByRole('menuitem', { name: '仅可查看' }).click()
+    await dialog.getByRole('button', { name: '仅可查看' }).waitFor({ timeout: 10_000 })
 
 
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(document).toContain('permission:')
     expect(document).toContain('permission:')
@@ -170,14 +170,14 @@ describe('web e2e: settings modal and General preferences', () => {
       ['approval/policy', { policy: 'ask' }],
       ['approval/policy', { policy: 'ask' }],
     ])
     ])
 
 
-    await dialog.getByRole('button', { name: 'Read Only' }).click()
-    await page.getByRole('menuitem', { name: 'Full access' }).click()
-    const confirmation = page.getByRole('dialog', { name: '确认启用 Full access?' })
-    const enable = confirmation.getByRole('button', { name: '启用 Full access' })
+    await dialog.getByRole('button', { name: '仅可查看' }).click()
+    await page.getByRole('menuitem', { name: '完全权限' }).click()
+    const confirmation = page.getByRole('dialog', { name: '确认启用完全权限?' })
+    const enable = confirmation.getByRole('button', { name: '启用完全权限' })
     expect(await enable.isDisabled()).toBe(true)
     expect(await enable.isDisabled()).toBe(true)
     await confirmation.getByRole('checkbox').click()
     await confirmation.getByRole('checkbox').click()
     await enable.click()
     await enable.click()
-    await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
+    await dialog.getByRole('button', { name: '完全权限' }).waitFor({ timeout: 10_000 })
     const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
     expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
     const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
     const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))

+ 2 - 2
docs/deepseek-llm-api-wire-extensions.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md
 #   pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md
-deepseek-llm-api-wire-extensions.md: e44246d81494d3e18c7b9eca492635442f9ee82f
-deepseek-llm-api-wire-extensions.zh.md: 1033ca6608479b6333d316aae830445c962fbbac
+deepseek-llm-api-wire-extensions.md: fd42609693ac6fbf91dd82b6e73b2d1d06e65a54
+deepseek-llm-api-wire-extensions.zh.md: 61af718841c8778e8943a1a1c16621a9a6618add

+ 2 - 2
docs/deepseek-llm-api-wire-extensions.md

@@ -128,7 +128,7 @@ The `session` member is the exact `Session.header`, not a complete runtime Sessi
 
 
 ### Canonical event envelopes
 ### Canonical event envelopes
 
 
-Each `events` item is a complete canonical `SessionEvent`, independent of every other request field. An event always carries `type`, `seq`, `time`, and `data`; surface events may additionally carry `sourceEventSeqs` and `surfaceOp`. The sender copies every present member without projection, redaction, or reconstruction.
+Each `events` item is a complete canonical `SessionEvent`, independent of every other request field. An event always carries `type`, `seq`, `time`, and `data`; it may carry `ignorable: true`, and surface events may additionally carry `sourceEventSeqs` and `surfaceOp`. The sender copies every present member without projection, redaction, or reconstruction.
 
 
 ### Acceptance watermark and at-least-once delivery
 ### Acceptance watermark and at-least-once delivery
 
 
@@ -156,4 +156,4 @@ Transport and non-2xx failures append no watermark. A crash after endpoint accep
 
 
 The request headers expose the Harness application version, one anonymous Harness-home identity, and an optional Session identity. `dsh_plugin_packages` exposes active npm package names and versions. When enabled, `dsh_session_log` may expose the Session working directory, system-prompt snapshots, user and assistant content, raw assistant chunks, tool arguments and results, compaction summaries, feedback, and plugin-owned events. Adapter API keys are not Session events and therefore do not enter the field. A gateway selected through `baseURL` receives the same values as the official endpoint.
 The request headers expose the Harness application version, one anonymous Harness-home identity, and an optional Session identity. `dsh_plugin_packages` exposes active npm package names and versions. When enabled, `dsh_session_log` may expose the Session working directory, system-prompt snapshots, user and assistant content, raw assistant chunks, tool arguments and results, compaction summaries, feedback, and plugin-owned events. Adapter API keys are not Session events and therefore do not enter the field. A gateway selected through `baseURL` receives the same values as the official endpoint.
 
 
-Receivers address extension fields by name, dispatch each field by its own `version`, preserve distinct package versions, and ignore JSON member ordering. A session-log receiver validates the contiguous sequence range before interpreting event types. Every unrecognized canonical event prevents lossless reconstruction. The base request remains usable without either the registry or a particular contribution; field absence means that contribution did not apply to that request.
+Receivers address extension fields by name, dispatch each field by its own `version`, preserve distinct package versions, and ignore JSON member ordering. A session-log receiver validates the contiguous sequence range before interpreting event types. An unrecognized canonical event without `ignorable: true` prevents lossless reconstruction. The base request remains usable without either the registry or a particular contribution; field absence means that contribution did not apply to that request.

+ 2 - 2
docs/deepseek-llm-api-wire-extensions.zh.md

@@ -128,7 +128,7 @@
 
 
 ### 权威事件信封
 ### 权威事件信封
 
 
-每个 `events` 元素都是完整的权威 `SessionEvent`,不依赖任何其他请求字段。事件始终携带 `type`、`seq`、`time` 与 `data`;展示事件还可携带 `sourceEventSeqs` 与 `surfaceOp`。发送方会复制每个已有成员,不执行投影、脱敏或重建。
+每个 `events` 元素都是完整的权威 `SessionEvent`,不依赖任何其他请求字段。事件始终携带 `type`、`seq`、`time` 与 `data`;它可以携带 `ignorable: true`,展示事件还可携带 `sourceEventSeqs` 与 `surfaceOp`。发送方会复制每个已有成员,不执行投影、脱敏或重建。
 
 
 ### 接受水位与至少一次交付
 ### 接受水位与至少一次交付
 
 
@@ -156,4 +156,4 @@
 
 
 请求标头会暴露 Harness 应用版本、一个匿名 Harness-home 身份和可选的会话身份。`dsh_plugin_packages` 会暴露存活 npm 包的名称与版本。启用后,`dsh_session_log` 可能暴露会话工作目录、系统提示词快照、用户与 assistant 内容、原始 assistant 分片、工具参数与结果、压缩摘要、反馈和插件持有的事件。适配器 API key 不是会话事件,因此不会进入该字段。通过 `baseURL` 选择的网关会收到与官方端点相同的值。
 请求标头会暴露 Harness 应用版本、一个匿名 Harness-home 身份和可选的会话身份。`dsh_plugin_packages` 会暴露存活 npm 包的名称与版本。启用后,`dsh_session_log` 可能暴露会话工作目录、系统提示词快照、用户与 assistant 内容、原始 assistant 分片、工具参数与结果、压缩摘要、反馈和插件持有的事件。适配器 API key 不是会话事件,因此不会进入该字段。通过 `baseURL` 选择的网关会收到与官方端点相同的值。
 
 
-接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。每个未知权威事件都会阻止无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。
+接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。遇到不带 `ignorable: true` 的未知权威事件时,接收方无法进行无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: 96985242249de9a528a834f6540c69bf44d7b688
-event-producer-consumer.zh.md: 4c16d6742a288bbaef09b38bbf6c87fafb2d6a60
+event-producer-consumer.md: feda8b9c2963a99851b289c443b589e19b4366da
+event-producer-consumer.zh.md: 88b736c00ffae08352a65b59c1eff84e9604b74a

+ 5 - 5
docs/event-producer-consumer.md

@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:529`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:508`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:515`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

+ 5 - 5
docs/event-producer-consumer.zh.md

@@ -23,11 +23,11 @@
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:529`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:508`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:515`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 92b15755387129fd8b762d8df772c3c3f05d3d0a
-persistence-catalog.zh.md: 1d72f792b8463efd22a476925297362f50130196
+persistence-catalog.md: a15b796e78de525874f8c28867e6748ba8779db8
+persistence-catalog.zh.md: f98485343e73b1bc777f6517a05cade49c77a15f

+ 12 - 1
docs/persistence-catalog.md

@@ -7,7 +7,7 @@ Every event type that can appear in a session's durable event log: the complete
 
 
 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. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).
 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. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).
 
 
-The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, 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`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.
+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`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.
 
 
 ## Event envelope
 ## Event envelope
 
 
@@ -63,6 +63,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
     /** Unix epoch milliseconds. */
     /** Unix epoch milliseconds. */
     time: number
     time: number
     data: SessionEventMap[K]
     data: SessionEventMap[K]
+    /**
+     * Marks an event a reader may safely skip when it does not recognize
+     * `type`. Absent means required: a reader meeting an unrecognized type
+     * without this marker MUST refuse to reconstruct the session instead of
+     * silently dropping the event, because an unrecognized required event may
+     * change how the rest of the log is interpreted. A writer sets `true` only
+     * on purely informational records whose loss cannot affect reconstruction;
+     * defaulting to required means a forgotten marker over-refuses (an
+     * inconvenience) rather than silently resuming a gutted session.
+     */
+    ignorable?: true
   } & (K extends SurfaceEventType ? {
   } & (K extends SurfaceEventType ? {
     /**
     /**
      * Seq numbers of earlier events that this event cites as sources
      * Seq numbers of earlier events that this event cites as sources

+ 12 - 1
docs/persistence-catalog.zh.md

@@ -9,7 +9,7 @@
 
 
 英文源文件根据源码生成(`scripts/gen-persistence-catalog.ts`),并由 `pnpm run verify-persistence-catalog`(`doc-sync`(文档同步门禁)的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块保留源码声明和嵌套属性的 JSDoc,只移除其所在接口/模块带来的缩进,并使用 `ts persistence-catalog` 围栏(doc-typecheck 会跳过这些围栏,因为声明引用了其所属模块中的类型)。payload 中的类型名称会链接到记录该类型的页面。参见 [persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md)。
 英文源文件根据源码生成(`scripts/gen-persistence-catalog.ts`),并由 `pnpm run verify-persistence-catalog`(`doc-sync`(文档同步门禁)的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块保留源码声明和嵌套属性的 JSDoc,只移除其所在接口/模块带来的缩进,并使用 `ts persistence-catalog` 围栏(doc-typecheck 会跳过这些围栏,因为声明引用了其所属模块中的类型)。payload 中的类型名称会链接到记录该类型的页面。参见 [persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md)。
 
 
-以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.zh.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。
+以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`、可选的未知类型跳过标记 `ignorable`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.zh.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。
 
 
 ## 事件信封
 ## 事件信封
 
 
@@ -65,6 +65,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
     /** Unix epoch milliseconds. */
     /** Unix epoch milliseconds. */
     time: number
     time: number
     data: SessionEventMap[K]
     data: SessionEventMap[K]
+    /**
+     * Marks an event a reader may safely skip when it does not recognize
+     * `type`. Absent means required: a reader meeting an unrecognized type
+     * without this marker MUST refuse to reconstruct the session instead of
+     * silently dropping the event, because an unrecognized required event may
+     * change how the rest of the log is interpreted. A writer sets `true` only
+     * on purely informational records whose loss cannot affect reconstruction;
+     * defaulting to required means a forgotten marker over-refuses (an
+     * inconvenience) rather than silently resuming a gutted session.
+     */
+    ignorable?: true
   } & (K extends SurfaceEventType ? {
   } & (K extends SurfaceEventType ? {
     /**
     /**
      * Seq numbers of earlier events that this event cites as sources
      * Seq numbers of earlier events that this event cites as sources

+ 2 - 2
docs/subsystems/persistence.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/persistence.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/persistence.md
-persistence.md: f73b9ab01c232c4d4fec5aa51e5250c60b9337da
-persistence.zh.md: 061c29f6b54c41137e3c764e9a7804f411f63f17
+persistence.md: 697cbf38ddbdea88947da86d10fc05b0cbc9ddb0
+persistence.zh.md: 6e12ed26fda7d11a8dfe6fd6575395d2f1b5500a

+ 2 - 2
docs/subsystems/persistence.md

@@ -91,7 +91,7 @@ interface SessionHeader {
 
 
 ## Format refusal — logs a build cannot faithfully read
 ## Format refusal — logs a build cannot faithfully read
 
 
-A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated set (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) also refuses reconstruction because silently skipping it could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating this format version's header fields or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [fail-closed event-vocabulary note](../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md).
+A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating this format version's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md).
 
 
 ## `CreateSessionOptions` — seeding and metadata
 ## `CreateSessionOptions` — seeding and metadata
 
 
@@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot {
 All implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite:
 All implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite:
 
 
 - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path.
 - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path.
-- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 19 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them.
+- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 20 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them.
 
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
 

+ 2 - 2
docs/subsystems/persistence.zh.md

@@ -91,7 +91,7 @@ interface SessionHeader {
 
 
 ## 格式拒绝:本构建无法可靠读取的日志
 ## 格式拒绝:本构建无法可靠读取的日志
 
 
-后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成集合(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型也会拒绝重建,因为静默跳过该事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于校验本格式版本的 header 字段和解码任何事件行,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见[事件词汇表显式拒绝 Agent Note](../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md)。
+后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于本格式版本的 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md)。
 
 
 ## `CreateSessionOptions`:seed 与元数据
 ## `CreateSessionOptions`:seed 与元数据
 
 
@@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot {
 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件:
 两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件:
 
 
 - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。
 - **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。
-- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 19 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
+- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 20 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
 
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
 

+ 2 - 2
docs/subsystems/session.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/session.md
-session.md: 94df89fb74a17ec64de0f643da31c652cdfcf853
-session.zh.md: 1b0f2a8927fb4dded5e18002cb3d6548c06c3eb4
+session.md: d35395fb80a6558e15b82cdb76559f59e492f425
+session.zh.md: d3bc750b5c198e38efa94c88f95dcb9406c9059b

+ 11 - 0
docs/subsystems/session.md

@@ -201,6 +201,17 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
     /** Unix epoch milliseconds. */
     /** Unix epoch milliseconds. */
     time: number
     time: number
     data: SessionEventMap[K]
     data: SessionEventMap[K]
+    /**
+     * Marks an event a reader may safely skip when it does not recognize
+     * `type`. Absent means required: a reader meeting an unrecognized type
+     * without this marker MUST refuse to reconstruct the session instead of
+     * silently dropping the event, because an unrecognized required event may
+     * change how the rest of the log is interpreted. A writer sets `true` only
+     * on purely informational records whose loss cannot affect reconstruction;
+     * defaulting to required means a forgotten marker over-refuses (an
+     * inconvenience) rather than silently resuming a gutted session.
+     */
+    ignorable?: true
   } & (K extends SurfaceEventType ? {
   } & (K extends SurfaceEventType ? {
     /**
     /**
      * Seq numbers of earlier events that this event cites as sources
      * Seq numbers of earlier events that this event cites as sources

+ 11 - 0
docs/subsystems/session.zh.md

@@ -201,6 +201,17 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
     /** Unix epoch milliseconds. */
     /** Unix epoch milliseconds. */
     time: number
     time: number
     data: SessionEventMap[K]
     data: SessionEventMap[K]
+    /**
+     * Marks an event a reader may safely skip when it does not recognize
+     * `type`. Absent means required: a reader meeting an unrecognized type
+     * without this marker MUST refuse to reconstruct the session instead of
+     * silently dropping the event, because an unrecognized required event may
+     * change how the rest of the log is interpreted. A writer sets `true` only
+     * on purely informational records whose loss cannot affect reconstruction;
+     * defaulting to required means a forgotten marker over-refuses (an
+     * inconvenience) rather than silently resuming a gutted session.
+     */
+    ignorable?: true
   } & (K extends SurfaceEventType ? {
   } & (K extends SurfaceEventType ? {
     /**
     /**
      * Seq numbers of earlier events that this event cites as sources
      * Seq numbers of earlier events that this event cites as sources

+ 1 - 0
packages/api/session-controller/src/types.ts

@@ -409,6 +409,7 @@ export interface SessionWireEvent {
   readonly seq: number
   readonly seq: number
   readonly time: number
   readonly time: number
   readonly data: JsonValue
   readonly data: JsonValue
+  readonly ignorable?: true
   readonly sourceEventSeqs?: number[]
   readonly sourceEventSeqs?: number[]
   readonly surfaceOp?: SurfaceOp
   readonly surfaceOp?: SurfaceOp
 }
 }

+ 2 - 2
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -160,11 +160,11 @@ describe('Session attachment authorization', () => {
     const inserted = imageRef('inserted')
     const inserted = imageRef('inserted')
     const streamed = imageRef('streamed')
     const streamed = imageRef('streamed')
     const events = [
     const events = [
-      event('fixture/direct', 0, {
+      { ...event('fixture/direct', 0, {
         content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
         content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
           type: 'tool-result', content: [{ type: 'image', attachment: nested }],
           type: 'tool-result', content: [{ type: 'image', attachment: nested }],
         }],
         }],
-      }),
+      }), ignorable: true as const },
       { ...event('assistant/message', 1, {
       { ...event('assistant/message', 1, {
         turn: 1,
         turn: 1,
         step: 1,
         step: 1,

+ 1 - 0
packages/api/session-controller/tests/transport.host.spec.ts

@@ -30,6 +30,7 @@ function event(type: string, seq: number, data: unknown = {}): SessionEvent {
     seq,
     seq,
     time: seq + 1,
     time: seq + 1,
     data,
     data,
+    ...type.startsWith('fixture/') ? { ignorable: true } : {},
   } as SessionEvent
   } as SessionEvent
 }
 }
 
 

+ 9 - 5
packages/client/ui-conversation/src/client/locales.ts

@@ -56,12 +56,14 @@ export const zh = {
   'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
   'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
   'settings.enter.queue': '排队发送',
   'settings.enter.queue': '排队发送',
   'settings.enter.steer': '插话发送',
   'settings.enter.steer': '插话发送',
-  'access.confirm.title': '确认启用 Full access?',
-  'access.confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
+  'access.preset.readOnly': '仅可查看',
+  'access.preset.workspaceWrite': '可写入工作区',
+  'access.preset.fullAccess': '完全权限',
+  'access.confirm.title': '确认启用完全权限?',
+  'access.confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
   'access.confirm.acknowledge': '我已了解风险,并愿意继续',
   'access.confirm.acknowledge': '我已了解风险,并愿意继续',
   'access.confirm.cancel': '取消',
   'access.confirm.cancel': '取消',
-  'access.confirm.enable': '启用 Full access',
-  'access.fullLabel': 'Full access',
+  'access.confirm.enable': '启用完全权限',
   'hero.headline': '探索未至之境',
   'hero.headline': '探索未至之境',
   'hero.preview': '预览版',
   'hero.preview': '预览版',
   'hero.chooseWorkspace': '选择工作区',
   'hero.chooseWorkspace': '选择工作区',
@@ -204,12 +206,14 @@ export const en = {
   'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
   'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
   'settings.enter.queue': 'Queue',
   'settings.enter.queue': 'Queue',
   'settings.enter.steer': 'Steer',
   'settings.enter.steer': 'Steer',
+  'access.preset.readOnly': 'Read Only',
+  'access.preset.workspaceWrite': 'Workspace Write',
+  'access.preset.fullAccess': 'Full access',
   'access.confirm.title': 'Enable Full access?',
   'access.confirm.title': 'Enable Full access?',
   'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
   'access.confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
   'access.confirm.acknowledge': 'I understand the risks and want to continue',
   'access.confirm.acknowledge': 'I understand the risks and want to continue',
   'access.confirm.cancel': 'Cancel',
   'access.confirm.cancel': 'Cancel',
   'access.confirm.enable': 'Enable Full access',
   'access.confirm.enable': 'Enable Full access',
-  'access.fullLabel': 'Full access',
   'hero.headline': 'Into the Unknown',
   'hero.headline': 'Into the Unknown',
   'hero.preview': 'Preview',
   'hero.preview': 'Preview',
   'hero.chooseWorkspace': 'Choose workspace',
   'hero.chooseWorkspace': 'Choose workspace',

+ 38 - 20
packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx

@@ -5,6 +5,7 @@ import type { PermissionSelect as PermissionSelectValue } from '@deepseek-ai/dsh
 import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
 import { IconChevronDownOutline14, Menu, RiskConfirmation } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ComposerBarProps } from '../contract/slots.ts'
 import type { ComposerBarProps } from '../contract/slots.ts'
+import { en } from '../locales.ts'
 import css from './PermissionSelect.module.css'
 import css from './PermissionSelect.module.css'
 
 
 const FULL_ACCESS = 'danger-full-access'
 const FULL_ACCESS = 'danger-full-access'
@@ -15,14 +16,14 @@ const FULL_ACCESS = 'danger-full-access'
 
 
 const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
 const shieldOutline = 'M8.20554 0.899994L14.7901 3.36857V7.01026C14.7901 12 11.0466 14.2103 8.20554 15.3C5.36446 14.2103 1.62012 12 1.62012 7.01026V3.36857L8.20554 0.899994Z'
 
 
-const permissionGlyphs = {
-  'read-only': (
+const permissionGlyphs = new Map<string, ReactNode>([
+  ['read-only', (
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
       <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
       <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
       <path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
       <path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
     </svg>
     </svg>
-  ),
-  'workspace-write': (
+  )],
+  ['workspace-write', (
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
       <path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" />
       <path d="M8.08887 0.251709C8.20479 0.23085 8.32486 0.241168 8.43652 0.282959L15.0215 2.75171C15.2787 2.84819 15.4492 3.09414 15.4492 3.3689V7.0105C15.4492 7.10986 15.4441 7.2081 15.4414 7.30542C15.0285 7.07175 14.5905 6.87695 14.1309 6.73022V3.82495L8.20508 1.60327L2.2793 3.82495V7.0105C2.27936 9.7171 3.4745 11.5379 5.02734 12.7947C5.01025 12.9942 5 13.1962 5 13.4001C5.00001 13.7617 5.02722 14.1169 5.08008 14.4636C2.91555 13.0393 0.961014 10.752 0.960938 7.0105V3.3689C0.960938 3.09417 1.13146 2.84821 1.38867 2.75171L7.97461 0.282959L8.08887 0.251709Z" fill="currentColor" />
       <path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" />
       <path d="M11.3525 5.64688V6.85688H5V5.64688H11.3525Z" fill="currentColor" />
@@ -30,38 +31,48 @@ const permissionGlyphs = {
       <path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" />
       <path d="M14.6647 15.6852H10.0338C10.3878 15.3751 10.7567 15.0517 11.0772 14.7706C11.2531 14.6164 11.4144 14.4746 11.5511 14.3547H14.6647V15.6852Z" fill="currentColor" />
       <path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" />
       <path d="M8.14852 14.1308L7.33925 15.4976C7.22458 15.6912 7.42245 15.9194 7.63037 15.8333L9.09785 15.2254L15.0399 10.0719L14.0905 8.97733L8.14852 14.1308Z" fill="currentColor" />
     </svg>
     </svg>
-  ),
-  [FULL_ACCESS]: (
+  )],
+  [FULL_ACCESS, (
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
     <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
       <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
       <path d={shieldOutline} stroke="currentColor" strokeWidth="1.31831" strokeLinejoin="round" />
       <path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
       <path d="M9.10094 4.5V8.75939H7.59888V4.5H9.10094Z" fill="currentColor" />
       <path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
       <path d="M9.10094 9.8114V11.5H7.59888V9.8114H9.10094Z" fill="currentColor" />
     </svg>
     </svg>
-  ),
-} as Record<string, ReactNode>
+  )],
+])
 
 
 /** Glyph for a permission option value; host-configured names outside the design set get none. */
 /** Glyph for a permission option value; host-configured names outside the design set get none. */
 function permissionGlyph(value: string): ReactNode | undefined {
 function permissionGlyph(value: string): ReactNode | undefined {
-  return permissionGlyphs[value]
+  return permissionGlyphs.get(value)
 }
 }
 
 
 /**
 /**
- * Display transform: kebab-case machine names render as title-case labels
- * (`workspace-write` → `Workspace Write`); non-kebab host-configured names
- * pass through. Full access intentionally overrides the machine-name
- * transform so both permission surfaces use the product label `Full access`;
- * the warning body remains locale-aware.
+ * Display transform: built-in machine names render as locale product labels;
+ * non-kebab host-configured names pass through.
  */
  */
 function displayName(name: string): string {
 function displayName(name: string): string {
   if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
   if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) return name
   return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
   return name.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
 }
 }
 
 
-function optionLabel(
-  option: PermissionSelectValue['options'][number],
+const BUILT_IN_PERMISSION_NAMES = new Map<string, string>([
+  ['read-only', en['access.preset.readOnly']],
+  ['workspace-write', en['access.preset.workspaceWrite']],
+  [FULL_ACCESS, en['access.preset.fullAccess']],
+])
+
+function permissionLabel(
+  value: string,
+  name: string,
   t: ComposerBarProps['t'],
   t: ComposerBarProps['t'],
 ): string {
 ): string {
-  return option.value === FULL_ACCESS ? t('access.fullLabel') : displayName(option.name)
+  const builtInName = BUILT_IN_PERMISSION_NAMES.get(value)
+  if (builtInName !== undefined && (name === value || name === builtInName)) {
+    if (value === 'read-only') return t('access.preset.readOnly')
+    if (value === 'workspace-write') return t('access.preset.workspaceWrite')
+    if (value === FULL_ACCESS) return t('access.preset.fullAccess')
+  }
+  return displayName(name)
 }
 }
 
 
 export interface PermissionSelectProps {
 export interface PermissionSelectProps {
@@ -89,13 +100,20 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
 
 
   const currentValue = pick ?? value.currentValue
   const currentValue = pick ?? value.currentValue
   const current = value.options.find(option => option.value === currentValue)
   const current = value.options.find(option => option.value === currentValue)
+  const currentLabel = current === undefined
+    ? permissionLabel(currentValue, currentValue, t)
+    : permissionLabel(current.value, current.name, t)
   const busy = pick !== null || confirmation !== null
   const busy = pick !== null || confirmation !== null
 
 
   const items: MenuEntry[] = value.options
   const items: MenuEntry[] = value.options
     .filter(o => o.value !== 'custom')
     .filter(o => o.value !== 'custom')
     .map((option) => {
     .map((option) => {
       const icon = permissionGlyph(option.value)
       const icon = permissionGlyph(option.value)
-      return { id: option.value, label: optionLabel(option, t), ...icon === undefined ? {} : { icon } }
+      return {
+        id: option.value,
+        label: permissionLabel(option.value, option.name, t),
+        ...icon === undefined ? {} : { icon },
+      }
     })
     })
 
 
   const submit = (id: string): void => {
   const submit = (id: string): void => {
@@ -141,7 +159,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
           <button
           <button
             type="button"
             type="button"
             className={css.trigger}
             className={css.trigger}
-            aria-label={t('input.accessMode', { name: current === undefined ? displayName(currentValue) : optionLabel(current, t) })}
+            aria-label={t('input.accessMode', { name: currentLabel })}
             title={current?.description}
             title={current?.description}
             disabled={locked || busy}
             disabled={locked || busy}
             onClick={() => { setOpen(!open) }}
             onClick={() => { setOpen(!open) }}
@@ -149,7 +167,7 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect
             {permissionGlyph(currentValue) !== undefined && (
             {permissionGlyph(currentValue) !== undefined && (
               <span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
               <span className={css.triggerIcon} aria-hidden>{permissionGlyph(currentValue)}</span>
             )}
             )}
-            <span className={css.triggerLabel}>{current === undefined ? displayName(currentValue) : optionLabel(current, t)}</span>
+            <span className={css.triggerLabel}>{currentLabel}</span>
             <span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
             <span className={clsx(css.chevron, open && css.chevronOpen)} aria-hidden>
               <IconChevronDownOutline14 />
               <IconChevronDownOutline14 />
             </span>
             </span>

+ 3 - 1
packages/client/ui-conversation/tests/history-transport.perf.client.ts

@@ -85,6 +85,7 @@ const sessionWireEventSchema = z.object({
   seq: safeIntegerSchema,
   seq: safeIntegerSchema,
   time: safeIntegerSchema,
   time: safeIntegerSchema,
   data: z.json(),
   data: z.json(),
+  ignorable: z.literal(true).optional(),
   sourceEventSeqs: z.array(safeIntegerSchema).optional(),
   sourceEventSeqs: z.array(safeIntegerSchema).optional(),
   surfaceOp: z.json().optional(),
   surfaceOp: z.json().optional(),
 }).strict()
 }).strict()
@@ -266,7 +267,7 @@ function append<Type extends keyof SessionEventMap>(
   events: SessionEvent[],
   events: SessionEvent[],
   type: Type,
   type: Type,
   data: SessionEventMap[Type],
   data: SessionEventMap[Type],
-  options: { readonly surfaceOp?: 'append' } = {},
+  options: { readonly surfaceOp?: 'append'; readonly ignorable?: true } = {},
 ): void {
 ): void {
   const seq = events.length
   const seq = events.length
   events.push({ type, seq, time: TIME_ZERO + seq, data, ...options } as SessionEvent<Type>)
   events.push({ type, seq, time: TIME_ZERO + seq, data, ...options } as SessionEvent<Type>)
@@ -279,6 +280,7 @@ function appendSeparator(events: SessionEvent[], run: number, separator: number)
     seq,
     seq,
     time: TIME_ZERO + seq,
     time: TIME_ZERO + seq,
     data: { run, separator },
     data: { run, separator },
+    ignorable: true,
   } as SessionEvent)
   } as SessionEvent)
 }
 }
 
 

+ 36 - 17
packages/client/ui-conversation/tests/input-bar.client.spec.tsx

@@ -1330,24 +1330,43 @@ describe('command launcher chrome and control seats', () => {
     }
     }
     const { view } = bench({ permissions, command })
     const { view } = bench({ permissions, command })
     const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
     const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
-    // Title-case display is presentation only; the menu ids stay machine names.
-    expect(trigger.textContent).toBe('Read Only')
+    // Product-label display is presentation only; the menu ids stay machine names.
+    expect(trigger.textContent).toBe('仅可查看')
     expect([...trigger.querySelectorAll('svg')]
     expect([...trigger.querySelectorAll('svg')]
       .every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
       .every(icon => icon.closest('[aria-hidden="true"]') !== null)).toBe(true)
     fireEvent.click(trigger)
     fireEvent.click(trigger)
     const items = view.getAllByRole('menuitem')
     const items = view.getAllByRole('menuitem')
-    expect(items.map(o => o.textContent)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
+    expect(items.map(o => o.textContent)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
     fireEvent.click(items[1]!)
     fireEvent.click(items[1]!)
     // Optimistic pick + disable until admission resolves (command stub resolves true).
     // Optimistic pick + disable until admission resolves (command stub resolves true).
     const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
     const busy = view.getByLabelText(/^访问模式/) as HTMLButtonElement
-    expect(busy.textContent).toBe('Workspace Write')
+    expect(busy.textContent).toBe('可写入工作区')
     expect(busy.disabled).toBe(true)
     expect(busy.disabled).toBe(true)
     expect(command).toHaveBeenCalledWith('/permission workspace-write')
     expect(command).toHaveBeenCalledWith('/permission workspace-write')
     await act(async () => {})
     await act(async () => {})
     expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
     expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(false)
   })
   })
 
 
-  it('requires explicit risk acknowledgement before submitting Full access', async () => {
+  it('the Access chip preserves host labels for built-in preset values', () => {
+    const permissions = {
+      options: [
+        { value: 'read-only', name: 'Review Only' },
+        { value: 'workspace-write', name: 'Project Files' },
+        { value: 'danger-full-access', name: 'Operator Mode' },
+        { value: 'custom-mode', name: 'custom-mode' },
+        { value: '__proto__', name: '__proto__' },
+      ],
+      currentValue: 'workspace-write',
+    }
+    const { view } = bench({ permissions })
+    const trigger = view.getByLabelText(/^访问模式/) as HTMLButtonElement
+    expect(trigger.textContent).toBe('Project Files')
+    fireEvent.click(trigger)
+    expect(view.getAllByRole('menuitem').map(item => item.textContent))
+      .toEqual(['Review Only', 'Project Files', 'Operator Mode', 'Custom Mode', '__proto__'])
+  })
+
+  it('requires explicit risk acknowledgement before submitting full access', async () => {
     const command = vi.fn(() => Promise.resolve(true))
     const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
     const permissions = {
       options: [
       options: [
@@ -1358,11 +1377,11 @@ describe('command launcher chrome and control seats', () => {
     }
     }
     const { view } = bench({ permissions, command })
     const { view } = bench({ permissions, command })
     fireEvent.click(view.getByLabelText(/^访问模式/))
     fireEvent.click(view.getByLabelText(/^访问模式/))
-    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
 
 
     expect(command).not.toHaveBeenCalled()
     expect(command).not.toHaveBeenCalled()
-    expect(view.getByRole('dialog', { name: '确认启用 Full access?' })).toBeTruthy()
-    const enable = view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement
+    expect(view.getByRole('dialog', { name: '确认启用完全权限?' })).toBeTruthy()
+    const enable = view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement
     expect(enable.disabled).toBe(true)
     expect(enable.disabled).toBe(true)
 
 
     fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
     fireEvent.click(view.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }))
@@ -1372,11 +1391,11 @@ describe('command launcher chrome and control seats', () => {
     expect(command).toHaveBeenCalledOnce()
     expect(command).toHaveBeenCalledOnce()
     expect(command).toHaveBeenCalledWith('/permission danger-full-access')
     expect(command).toHaveBeenCalledWith('/permission danger-full-access')
     expect(view.queryByRole('dialog')).toBeNull()
     expect(view.queryByRole('dialog')).toBeNull()
-    expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Full access')
+    expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('完全权限')
     await act(async () => {})
     await act(async () => {})
   })
   })
 
 
-  it('cancels a Full access selection without changing permission and resets acknowledgement', () => {
+  it('cancels a full access selection without changing permission and resets acknowledgement', () => {
     const command = vi.fn(() => Promise.resolve(true))
     const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
     const permissions = {
       options: [
       options: [
@@ -1388,21 +1407,21 @@ describe('command launcher chrome and control seats', () => {
     const { view } = bench({ permissions, command })
     const { view } = bench({ permissions, command })
     const openConfirmation = () => {
     const openConfirmation = () => {
       fireEvent.click(view.getByLabelText(/^访问模式/))
       fireEvent.click(view.getByLabelText(/^访问模式/))
-      fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+      fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
     }
     }
 
 
     openConfirmation()
     openConfirmation()
     fireEvent.click(view.getByRole('checkbox'))
     fireEvent.click(view.getByRole('checkbox'))
     fireEvent.click(view.getByRole('button', { name: '取消' }))
     fireEvent.click(view.getByRole('button', { name: '取消' }))
     expect(command).not.toHaveBeenCalled()
     expect(command).not.toHaveBeenCalled()
-    expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('Workspace Write')
+    expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).textContent).toBe('可写入工作区')
 
 
     openConfirmation()
     openConfirmation()
     expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
     expect((view.getByRole('checkbox') as HTMLInputElement).checked).toBe(false)
-    expect((view.getByRole('button', { name: '启用 Full access' }) as HTMLButtonElement).disabled).toBe(true)
+    expect((view.getByRole('button', { name: '启用完全权限' }) as HTMLButtonElement).disabled).toBe(true)
   })
   })
 
 
-  it('revokes an open Full access confirmation when the task locks', () => {
+  it('revokes an open full access confirmation when the task locks', () => {
     const command = vi.fn(() => Promise.resolve(true))
     const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
     const permissions = {
       options: [
       options: [
@@ -1413,14 +1432,14 @@ describe('command launcher chrome and control seats', () => {
     }
     }
     const { view, session } = bench({ permissions, command })
     const { view, session } = bench({ permissions, command })
     fireEvent.click(view.getByLabelText(/^访问模式/))
     fireEvent.click(view.getByLabelText(/^访问模式/))
-    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
     fireEvent.click(view.getByRole('checkbox'))
     fireEvent.click(view.getByRole('checkbox'))
     act(() => { session.set(snapshotOf({ removed: true })) })
     act(() => { session.set(snapshotOf({ removed: true })) })
     expect(view.queryByRole('dialog')).toBeNull()
     expect(view.queryByRole('dialog')).toBeNull()
     expect(command).not.toHaveBeenCalled()
     expect(command).not.toHaveBeenCalled()
   })
   })
 
 
-  it('resets an open Full access confirmation when switching tasks', () => {
+  it('resets an open full access confirmation when switching tasks', () => {
     const command = vi.fn(() => Promise.resolve(true))
     const command = vi.fn(() => Promise.resolve(true))
     const permissions = {
     const permissions = {
       options: [
       options: [
@@ -1431,7 +1450,7 @@ describe('command launcher chrome and control seats', () => {
     }
     }
     const { view, props } = bench({ permissions, command })
     const { view, props } = bench({ permissions, command })
     fireEvent.click(view.getByLabelText(/^访问模式/))
     fireEvent.click(view.getByLabelText(/^访问模式/))
-    fireEvent.click(view.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(view.getByRole('menuitem', { name: '完全权限' }))
     fireEvent.click(view.getByRole('checkbox'))
     fireEvent.click(view.getByRole('checkbox'))
     view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
     view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />)
     expect(view.queryByRole('dialog')).toBeNull()
     expect(view.queryByRole('dialog')).toBeNull()

+ 2 - 2
packages/client/ui-permission-presets/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-permission-presets/README.md
 #   pnpm run verify-translation-pairing --write packages/client/ui-permission-presets/README.md
-README.md: fc7328bd0029ea86b369d3457c43aa4e24db1243
-README.zh.md: 523ea66a0f7fa3eeddb0f8fe1a901d03f9720c3d
+README.md: 6a82ebe45edb7e6055bf4c69a2e90bf21255696c
+README.zh.md: bd5588b13cc55d856163ff3eadf04f8059b2f7a9

+ 3 - 3
packages/client/ui-permission-presets/README.md

@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 
 ## Summary
 ## Summary
 
 
-This package provides permission preset surfaces for two lifetimes in the Web GUI: a General-settings row chooses the default for later sessions without switching the current session. A picker on the host `/permission` command switches the current session through one flat preset list with the active value marked. Kebab-case names render as title-case labels, and `danger-full-access` is presented as `Full access`. Choosing full access requires an explicit risk acknowledgement before either surface writes it. Both surfaces read one host-computed projection and write through one path, so the pushed projection frame is the single confirmation both follow.
+This package provides permission preset surfaces for two lifetimes in the Web GUI: a General-settings row chooses the default for later sessions without switching the current session. A picker on the host `/permission` command switches the current session through one flat preset list with the active value marked. Canonical built-in names render as locale-owned product labels, explicit host labels remain unchanged, and unknown kebab-case names render in title case. Choosing full access requires an explicit risk acknowledgement before either surface writes it. Both surfaces read one host-computed projection and write through one path, so the pushed projection frame is the single confirmation both follow.
 
 
 ## Table of Contents
 ## Table of Contents
 
 
@@ -29,11 +29,11 @@ Mount this plugin alongside the settings and commands packages; the permission r
 
 
 ### The picker
 ### The picker
 
 
-A pick submits the `/permission <preset>` command line. The argued path (`/permission <preset>` typed directly) still switches directly; the decoration replaces only the bare invocation. Unknown kebab-case preset names render in title case, and `custom` is display state, never a target.
+A pick submits the `/permission <preset>` command line. The argued path (`/permission <preset>` typed directly) still switches directly; the decoration replaces only the bare invocation. The built-in labels are `Read Only`, `Workspace Write`, and `Full access` in English and `仅可查看`, `可写入工作区`, and `完全权限` in Chinese; `custom` is display state, never a target.
 
 
 ### The Settings row
 ### The Settings row
 
 
-The row derives its options from the host's dynamic `defaultPreset` enum and writes one settings mutation. The value applies only when a later session is created; changing it never switches or rewrites the current session.
+The row derives its options from the host's dynamic `defaultPreset` enum, uses the same localized labels as the current-session picker, and writes one settings mutation. The value applies only when a later session is created; changing it never switches or rewrites the current session.
 
 
 -----
 -----
 
 

+ 3 - 3
packages/client/ui-permission-presets/README.zh.md

@@ -9,7 +9,7 @@ kind: "package-reference"
 
 
 ## 概述
 ## 概述
 
 
-本包为 Web GUI 中两种生命周期提供权限预设表面:通用设置中的一行选择之后创建会话所用的默认值,但不会切换当前会话。挂在宿主 `/permission` 命令上的选择器通过一张扁平预设列表切换当前会话,并标记 active 值。Kebab-case 名称渲染为 Title Case 标签,`danger-full-access` 显示为 `Full access`。选择完全权限时,该行或选择器写入前必须先显式确认风险。两个表面读取同一份宿主计算的投影、经同一条路径写入,因此推送的投影帧是两者共同跟随的唯一确认。
+本包为 Web GUI 中两种生命周期提供权限预设表面:通用设置中的一行选择之后创建会话所用的默认值,但不会切换当前会话。挂在宿主 `/permission` 命令上的选择器通过一张扁平预设列表切换当前会话,并标记 active 值。规范内置名称渲染为 locale 所有的产品标签,显式 host 标签保持原样,未知 kebab-case 名称渲染为 Title Case。选择完全权限时,该行或选择器写入前必须先显式确认风险。两个表面读取同一份宿主计算的投影、经同一条路径写入,因此推送的投影帧是两者共同跟随的唯一确认。
 
 
 ## 目录
 ## 目录
 
 
@@ -29,11 +29,11 @@ kind: "package-reference"
 
 
 ### 选择器
 ### 选择器
 
 
-选中即提交 `/permission <preset>` 命令行。带参路径(直接键入 `/permission <preset>`)仍直接切换;装饰只替换裸调用。未知 kebab-case 预设名渲染为 Title Case 标签,`custom` 只是显示状态,绝非目标。
+选中即提交 `/permission <preset>` 命令行。带参路径(直接键入 `/permission <preset>`)仍直接切换;装饰只替换裸调用。内置标签在英文界面中是 `Read Only`、`Workspace Write` 和 `Full access`,在中文界面中是「仅可查看」「可写入工作区」和「完全权限」;`custom` 只是显示状态,绝非目标。
 
 
 ### 设置行
 ### 设置行
 
 
-该行从宿主动态的 `defaultPreset` enum 推导选项,写入一条设置变更操作。该值只在之后创建会话时生效;改变它绝不会切换或改写当前会话。
+该行从宿主动态的 `defaultPreset` enum 推导选项,使用与当前会话选择器相同的本地化标签,并写入一条设置变更操作。该值只在之后创建会话时生效;改变它绝不会切换或改写当前会话。
 
 
 -----
 -----
 
 

+ 5 - 4
packages/client/ui-permission-presets/src/client/PermissionRow.tsx

@@ -12,7 +12,7 @@ import {
 } from '@deepseek-ai/dsh-client-ui-primitives'
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { PermissionSettingsState } from './settings-store.ts'
 import type { PermissionSettingsState } from './settings-store.ts'
 import type { PermissionSettingsKey } from './locales.ts'
 import type { PermissionSettingsKey } from './locales.ts'
-import { FULL_ACCESS_PRESET } from './presentation.ts'
+import { displayPermissionPreset, FULL_ACCESS_PRESET } from './presentation.ts'
 import css from './PermissionRow.module.css'
 import css from './PermissionRow.module.css'
 
 
 /** Registration-side business face for the host-backed preference. */
 /** Registration-side business face for the host-backed preference. */
@@ -58,8 +58,9 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
   if (state.status === 'unavailable') return null
   if (state.status === 'unavailable') return null
   const selected = state.options.find(option => option.id === state.currentValue)
   const selected = state.options.find(option => option.id === state.currentValue)
   const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess
   const busy = state.status === 'loading' || state.status === 'saving' || confirmingFullAccess
-  const label = selected?.label
-    ?? (busy ? t('loading') : t('unavailable'))
+  const optionLabel = (option: PermissionSettingsState['options'][number]): string =>
+    displayPermissionPreset(option.id, option.label, t)
+  const label = selected !== undefined ? optionLabel(selected) : (busy ? t('loading') : t('unavailable'))
   const description: string = state.error ?? t('description')
   const description: string = state.error ?? t('description')
 
 
   return (
   return (
@@ -72,7 +73,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP
         <Menu
         <Menu
           open={open}
           open={open}
           onClose={() => { setOpen(false) }}
           onClose={() => { setOpen(false) }}
-          items={state.options.map(option => ({ id: option.id, label: option.label }))}
+          items={state.options.map(option => ({ id: option.id, label: optionLabel(option) }))}
           selectedId={state.currentValue}
           selectedId={state.currentValue}
           onSelect={(id) => {
           onSelect={(id) => {
             setOpen(false)
             setOpen(false)

+ 7 - 1
packages/client/ui-permission-presets/src/client/index.ts

@@ -61,7 +61,7 @@ function optionsOf(value: PermissionSelect, t: (key: string) => string): SelectO
     .filter(option => option.value !== 'custom')
     .filter(option => option.value !== 'custom')
     .map(option => ({
     .map(option => ({
       id: option.value,
       id: option.value,
-      label: displayPermissionPreset(option.value, option.name),
+      label: displayPermissionPreset(option.value, option.name, t),
       ...(option.description !== undefined ? { detail: option.description } : {}),
       ...(option.description !== undefined ? { detail: option.description } : {}),
       ...(option.value === value.currentValue ? { active: true } : {}),
       ...(option.value === value.currentValue ? { active: true } : {}),
       ...(option.value === FULL_ACCESS_PRESET
       ...(option.value === FULL_ACCESS_PRESET
@@ -92,6 +92,9 @@ export function apply(ctx: ClientContext): void {
   ctx.effect(() => {
   ctx.effect(() => {
     const disposers = [
     const disposers = [
       ctx.locale.register(ACCESS_NS, 'zh', {
       ctx.locale.register(ACCESS_NS, 'zh', {
+        'preset.readOnly': accessZh['preset.readOnly'],
+        'preset.workspaceWrite': accessZh['preset.workspaceWrite'],
+        'preset.fullAccess': accessZh['preset.fullAccess'],
         'confirm.title': accessZh['confirm.title'],
         'confirm.title': accessZh['confirm.title'],
         'confirm.description': accessZh['confirm.description'],
         'confirm.description': accessZh['confirm.description'],
         'confirm.acknowledge': accessZh['confirm.acknowledge'],
         'confirm.acknowledge': accessZh['confirm.acknowledge'],
@@ -99,6 +102,9 @@ export function apply(ctx: ClientContext): void {
         'confirm.enable': accessZh['confirm.enable'],
         'confirm.enable': accessZh['confirm.enable'],
       }),
       }),
       ctx.locale.register(ACCESS_NS, 'en', {
       ctx.locale.register(ACCESS_NS, 'en', {
+        'preset.readOnly': accessEn['preset.readOnly'],
+        'preset.workspaceWrite': accessEn['preset.workspaceWrite'],
+        'preset.fullAccess': accessEn['preset.fullAccess'],
         'confirm.title': accessEn['confirm.title'],
         'confirm.title': accessEn['confirm.title'],
         'confirm.description': accessEn['confirm.description'],
         'confirm.description': accessEn['confirm.description'],
         'confirm.acknowledge': accessEn['confirm.acknowledge'],
         'confirm.acknowledge': accessEn['confirm.acknowledge'],

+ 18 - 6
packages/client/ui-permission-presets/src/client/locales.ts

@@ -6,11 +6,14 @@ export const zh = {
   'description': '选择新会话的默认权限模式',
   'description': '选择新会话的默认权限模式',
   'loading': '加载中',
   'loading': '加载中',
   'unavailable': '不可用',
   'unavailable': '不可用',
-  'confirm.title': '确认启用 Full access?',
-  'confirm.description': '启用 Full access 后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
+  'preset.readOnly': '仅可查看',
+  'preset.workspaceWrite': '可写入工作区',
+  'preset.fullAccess': '完全权限',
+  'confirm.title': '确认启用完全权限?',
+  'confirm.description': '启用完全权限后,新会话将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任后续任务时使用。',
   'confirm.acknowledge': '我已了解风险,并愿意继续',
   'confirm.acknowledge': '我已了解风险,并愿意继续',
   'confirm.cancel': '取消',
   'confirm.cancel': '取消',
-  'confirm.enable': '启用 Full access',
+  'confirm.enable': '启用完全权限',
 } satisfies Record<string, string>
 } satisfies Record<string, string>
 
 
 /** The settings.permission namespace key union. */
 /** The settings.permission namespace key union. */
@@ -22,6 +25,9 @@ export const en = {
   'description': 'Choose the default permission mode for new sessions',
   'description': 'Choose the default permission mode for new sessions',
   'loading': 'Loading',
   'loading': 'Loading',
   'unavailable': 'Unavailable',
   'unavailable': 'Unavailable',
+  'preset.readOnly': 'Read Only',
+  'preset.workspaceWrite': 'Workspace Write',
+  'preset.fullAccess': 'Full access',
   'confirm.title': 'Enable Full access?',
   'confirm.title': 'Enable Full access?',
   'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
   'confirm.description': 'Full access lets new sessions reduce confirmation steps and perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust subsequent tasks.',
   'confirm.acknowledge': 'I understand the risks and want to continue',
   'confirm.acknowledge': 'I understand the risks and want to continue',
@@ -31,11 +37,14 @@ export const en = {
 
 
 /** Simplified Chinese dictionary for the current-session popup gate. */
 /** Simplified Chinese dictionary for the current-session popup gate. */
 export const accessZh = {
 export const accessZh = {
-  'confirm.title': '确认启用 Full access?',
-  'confirm.description': '启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
+  'preset.readOnly': '仅可查看',
+  'preset.workspaceWrite': '可写入工作区',
+  'preset.fullAccess': '完全权限',
+  'confirm.title': '确认启用完全权限?',
+  'confirm.description': '启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。',
   'confirm.acknowledge': '我已了解风险,并愿意继续',
   'confirm.acknowledge': '我已了解风险,并愿意继续',
   'confirm.cancel': '取消',
   'confirm.cancel': '取消',
-  'confirm.enable': '启用 Full access',
+  'confirm.enable': '启用完全权限',
 } satisfies Record<string, string>
 } satisfies Record<string, string>
 
 
 /** Current-session popup-gate key union. */
 /** Current-session popup-gate key union. */
@@ -43,6 +52,9 @@ export type PermissionAccessKey = keyof typeof accessZh
 
 
 /** English dictionary for the current-session popup gate. */
 /** English dictionary for the current-session popup gate. */
 export const accessEn = {
 export const accessEn = {
+  'preset.readOnly': 'Read Only',
+  'preset.workspaceWrite': 'Workspace Write',
+  'preset.fullAccess': 'Full access',
   'confirm.title': 'Enable Full access?',
   'confirm.title': 'Enable Full access?',
   'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
   'confirm.description': 'Full access reduces confirmation steps and lets the agent perform more actions directly, including sensitive operations, file changes, or external commands. Only use it when you trust the current task.',
   'confirm.acknowledge': 'I understand the risks and want to continue',
   'confirm.acknowledge': 'I understand the risks and want to continue',

+ 32 - 3
packages/client/ui-permission-presets/src/client/presentation.ts

@@ -1,6 +1,26 @@
+import { en } from './locales.ts'
+
 /** Machine value of the preset that requires an explicit GUI risk gate. */
 /** Machine value of the preset that requires an explicit GUI risk gate. */
 export const FULL_ACCESS_PRESET = 'danger-full-access'
 export const FULL_ACCESS_PRESET = 'danger-full-access'
 
 
+/** Locale dictionary key for a built-in permission preset label. */
+export type PermissionPresetLabelKey =
+  | 'preset.readOnly'
+  | 'preset.workspaceWrite'
+  | 'preset.fullAccess'
+
+const PRESET_LABEL_KEYS = new Map<string, PermissionPresetLabelKey>([
+  ['read-only', 'preset.readOnly'],
+  ['workspace-write', 'preset.workspaceWrite'],
+  [FULL_ACCESS_PRESET, 'preset.fullAccess'],
+])
+
+const DEFAULT_PRESET_LABELS: Record<PermissionPresetLabelKey, string> = {
+  'preset.readOnly': en['preset.readOnly'],
+  'preset.workspaceWrite': en['preset.workspaceWrite'],
+  'preset.fullAccess': en['preset.fullAccess'],
+}
+
 /**
 /**
  * Convert conventional kebab-case preset names into user-facing title case.
  * Convert conventional kebab-case preset names into user-facing title case.
  * @param name - host-supplied preset label or key.
  * @param name - host-supplied preset label or key.
@@ -15,8 +35,17 @@ export function displayPresetName(name: string): string {
  * Render a permission preset under its product label.
  * Render a permission preset under its product label.
  * @param value - preset machine value.
  * @param value - preset machine value.
  * @param name - host-supplied preset name.
  * @param name - host-supplied preset name.
- * @returns the Full access product label or the conventional display name.
+ * @param t - optional locale dictionary lookup for built-in product labels.
+ * @returns the built-in product label or the conventional display name.
  */
  */
-export function displayPermissionPreset(value: string, name: string): string {
-  return value === FULL_ACCESS_PRESET ? 'Full access' : displayPresetName(name)
+export function displayPermissionPreset(
+  value: string,
+  name: string,
+  t?: (key: PermissionPresetLabelKey) => string,
+): string {
+  const key = PRESET_LABEL_KEYS.get(value)
+  if (key !== undefined && (name === value || name === DEFAULT_PRESET_LABELS[key])) {
+    return t?.(key) ?? DEFAULT_PRESET_LABELS[key]
+  }
+  return displayPresetName(name)
 }
 }

+ 23 - 5
packages/client/ui-permission-presets/tests/browser-plugin.client.spec.ts

@@ -21,7 +21,7 @@ import {
   PermissionRow, type PermissionRowInjected,
   PermissionRow, type PermissionRowInjected,
 } from '../src/client/PermissionRow.tsx'
 } from '../src/client/PermissionRow.tsx'
 import { apply, inject } from '../src/client/index.ts'
 import { apply, inject } from '../src/client/index.ts'
-import { accessEn } from '../src/client/locales.ts'
+import { accessEn, accessZh } from '../src/client/locales.ts'
 
 
 const sid = (k: string): SessionId => k as SessionId
 const sid = (k: string): SessionId => k as SessionId
 
 
@@ -79,7 +79,7 @@ async function bench() {
   const fiber = ctx.plugin({ inject: [...inject], apply })
   const fiber = ctx.plugin({ inject: [...inject], apply })
   await fiber.await()
   await fiber.await()
   return {
   return {
-    ctx, fiber, values, commands, remote,
+    ctx, fiber, locale, values, commands, remote,
     setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
     setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
     decoration: () => decoration,
     decoration: () => decoration,
     permissionRow: () => ctx.slots.entries('settings.general.item')
     permissionRow: () => ctx.slots.entries('settings.general.item')
@@ -117,7 +117,7 @@ describe('ui-permission browser plugin', () => {
     const again = await c.ui.options(proj, new AbortController().signal)
     const again = await c.ui.options(proj, new AbortController().signal)
     expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
     expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
     expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
     expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
-    // Kebab-case names title-case; non-kebab host-configured names pass through.
+    // English built-ins use product labels; other kebab-case names title-case.
     expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
     expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
     expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
     expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
       title: 'Enable Full access?',
       title: 'Enable Full access?',
@@ -126,9 +126,27 @@ describe('ui-permission browser plugin', () => {
       cancelLabel: 'Cancel',
       cancelLabel: 'Cancel',
       confirmLabel: 'Enable Full access',
       confirmLabel: 'Enable Full access',
     })
     })
-    b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
+    b.locale.setLocale('zh')
+    const localized = await c.ui.options(proj, new AbortController().signal)
+    expect(localized.map(option => option.label)).toEqual(['仅可查看', '可写入工作区', '完全权限'])
+    expect(localized.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
+      title: '确认启用完全权限?',
+      description: accessZh['confirm.description'],
+      acknowledgeLabel: '我已了解风险,并愿意继续',
+      cancelLabel: '取消',
+      confirmLabel: '启用完全权限',
+    })
+    b.values.set(sid('s1'), { ...SELECT, options: [
+      { value: 'workspace-write', name: 'Project Files' },
+      { value: 'danger-full-access', name: 'Operator Mode' },
+      { value: 'custom-mode', name: 'custom-mode' },
+      { value: '__proto__', name: '__proto__' },
+      { value: 'plain', name: 'Ask Every Time' },
+    ] })
     const passthrough = await c.ui.options(proj, new AbortController().signal)
     const passthrough = await c.ui.options(proj, new AbortController().signal)
-    expect(passthrough[0]?.label).toBe('Ask Every Time')
+    expect(passthrough.map(option => option.label)).toEqual([
+      'Project Files', 'Operator Mode', 'Custom Mode', '__proto__', 'Ask Every Time',
+    ])
     // A projection that vanished between availability and open throws.
     // A projection that vanished between availability and open throws.
     expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
     expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
       .toThrow(/not available on this host/)
       .toThrow(/not available on this host/)

+ 19 - 19
packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx

@@ -6,7 +6,7 @@ import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-
 import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
 import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
 import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
 import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts'
 import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
 import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx'
-import { en } from '../src/client/locales.ts'
+import { zh } from '../src/client/locales.ts'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
 import { PermissionPresetSettingsController } from '../src/client/settings-store.ts'
 
 
@@ -48,7 +48,7 @@ function ok<T>(value: T) {
   return { ok: true as const, value }
   return { ok: true as const, value }
 }
 }
 
 
-const dictionary: Record<string, string> = en
+const dictionary: Record<string, string> = zh
 const t: PermissionRowProps['t'] = key => dictionary[key] ?? key
 const t: PermissionRowProps['t'] = key => dictionary[key] ?? key
 type AttentionSnapshot = Parameters<Parameters<PermissionRowProps['useSessionPendingInteraction']>[0]>[0]
 type AttentionSnapshot = Parameters<Parameters<PermissionRowProps['useSessionPendingInteraction']>[0]>[0]
 const noAttention: AttentionSnapshot = new Map()
 const noAttention: AttentionSnapshot = new Map()
@@ -81,7 +81,7 @@ describe('PermissionRow', () => {
       },
       },
     })
     })
     mount(controller)
     mount(controller)
-    const button = await screen.findByRole('button', { name: 'Read Only' })
+    const button = await screen.findByRole('button', { name: '仅可查看' })
     expect(button.getAttribute('aria-expanded')).toBe('false')
     expect(button.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(button)
     fireEvent.click(button)
     expect(button.getAttribute('aria-expanded')).toBe('true')
     expect(button.getAttribute('aria-expanded')).toBe('true')
@@ -91,15 +91,15 @@ describe('PermissionRow', () => {
     fireEvent.click(button)
     fireEvent.click(button)
     expect(button.getAttribute('aria-expanded')).toBe('false')
     expect(button.getAttribute('aria-expanded')).toBe('false')
     fireEvent.click(button)
     fireEvent.click(button)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Read Only' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '仅可查看' }))
     expect(mutate).not.toHaveBeenCalled()
     expect(mutate).not.toHaveBeenCalled()
     fireEvent.click(button)
     fireEvent.click(button)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
-    await screen.findByRole('button', { name: 'Workspace Write' })
+    fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
+    await screen.findByRole('button', { name: '可写入工作区' })
     expect(mutate).toHaveBeenCalledOnce()
     expect(mutate).toHaveBeenCalledOnce()
   })
   })
 
 
-  it('requires explicit acknowledgement before saving Full access', async () => {
+  it('requires explicit acknowledgement before saving full access', async () => {
     const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
     const mutate = vi.fn(() => Promise.resolve(ok(view('danger-full-access', 1))))
     const controller = derivedController({
     const controller = derivedController({
       settings: {
       settings: {
@@ -108,15 +108,15 @@ describe('PermissionRow', () => {
       },
       },
     })
     })
     mount(controller)
     mount(controller)
-    fireEvent.click(await screen.findByRole('button', { name: 'Read Only' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
+    fireEvent.click(await screen.findByRole('button', { name: '仅可查看' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
     expect(mutate).not.toHaveBeenCalled()
     expect(mutate).not.toHaveBeenCalled()
-    fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
-    expect(screen.queryByRole('dialog', { name: 'Enable Full access?' })).toBeNull()
-    fireEvent.click(screen.getByRole('button', { name: 'Read Only' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Full access' }))
-    const dialog = screen.getByRole('dialog', { name: 'Enable Full access?' })
-    const enable = screen.getByRole('button', { name: 'Enable Full access' })
+    fireEvent.click(screen.getByRole('button', { name: '取消' }))
+    expect(screen.queryByRole('dialog', { name: '确认启用完全权限?' })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: '仅可查看' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '完全权限' }))
+    const dialog = screen.getByRole('dialog', { name: '确认启用完全权限?' })
+    const enable = screen.getByRole('button', { name: '启用完全权限' })
     expect((enable as HTMLButtonElement).disabled).toBe(true)
     expect((enable as HTMLButtonElement).disabled).toBe(true)
     fireEvent.click(screen.getByRole('checkbox'))
     fireEvent.click(screen.getByRole('checkbox'))
     fireEvent.click(enable)
     fireEvent.click(enable)
@@ -142,7 +142,7 @@ describe('PermissionRow', () => {
       },
       },
     })
     })
     mount(readonly)
     mount(readonly)
-    expect((await screen.findByRole('button', { name: 'Read Only' })).hasAttribute('disabled')).toBe(true)
+    expect((await screen.findByRole('button', { name: '仅可查看' })).hasAttribute('disabled')).toBe(true)
   })
   })
 
 
   it('shows loading and a contained write error', async () => {
   it('shows loading and a contained write error', async () => {
@@ -162,11 +162,11 @@ describe('PermissionRow', () => {
       },
       },
     })
     })
     mount(controller)
     mount(controller)
-    expect((await screen.findByRole('button', { name: 'Loading' })).hasAttribute('disabled')).toBe(true)
+    expect((await screen.findByRole('button', { name: '加载中' })).hasAttribute('disabled')).toBe(true)
     describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
     describe.resolve(ok({ writable: true, hasDocument: false, namespaces: [view('read-only')] }))
-    const button = await screen.findByRole('button', { name: 'Read Only' })
+    const button = await screen.findByRole('button', { name: '仅可查看' })
     fireEvent.click(button)
     fireEvent.click(button)
-    fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace Write' }))
+    fireEvent.click(screen.getByRole('menuitem', { name: '可写入工作区' }))
     expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
     expect((await screen.findByRole('alert')).textContent).toBe('changed elsewhere')
   })
   })
 })
 })

+ 2 - 2
packages/core/session/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/core/session/README.md
 #   pnpm run verify-translation-pairing --write packages/core/session/README.md
-README.md: ddc1c7c350217aa03d058d445d7cae4a592eb6ef
-README.zh.md: 851f9f5c55602d7cb2e7cfff449102f0e8876969
+README.md: 0d691b31c4918152ecf1092002646296c5d9984a
+README.zh.md: 2118def74c059f6637f12a9aeba20ff92a5d2363

+ 1 - 1
packages/core/session/README.md

@@ -170,7 +170,7 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi
 These limits define when the session store needs special care. They are current package constraints, not a task backlog.
 These limits define when the session store needs special care. They are current package constraints, not a task backlog.
 
 
 - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
 - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md).
-- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, a backend refuses any other version, and every unknown event type refuses reconstruction ([mechanism](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md)).
+- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, a backend refuses any other version, and unknown event types refuse reconstruction unless marked `ignorable` in the envelope ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)).
 - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
 - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them.
 - **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking.
 - **No session tree beyond fork** — a pi-style entry tree over branched sessions is deferred unless a consumer needs more than boundary-based forking.
 
 

+ 1 - 1
packages/core/session/README.zh.md

@@ -170,7 +170,7 @@ session.deriveMessages()         // the derived model history
 这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。
 这些限制说明会话存储何时需要特别留意。它们是当前包约束,不是任务积压。
 
 
 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md) 不支持对已持久化但未加载的会话进行 fork。
 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.zh.md) 不支持对已持久化但未加载的会话进行 fork。
-- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝任何其他版本,每个不认识的事件类型也会拒绝重建([机制](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md))。
+- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝任何其他版本,不认识的事件类型也会拒绝重建,除非信封带 `ignorable` 标记([机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md))。
 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
 - **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。
 - **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。
 
 

+ 3 - 1
packages/core/session/src/index.ts

@@ -221,6 +221,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
       case 'data':
       case 'data':
       case 'surfaceOp':
       case 'surfaceOp':
       case 'sourceEventSeqs':
       case 'sourceEventSeqs':
+      case 'ignorable':
         break
         break
       default:
       default:
         throw new Error(`seed event at index ${index} has an invalid event envelope`)
         throw new Error(`seed event at index ${index} has an invalid event envelope`)
@@ -232,7 +233,8 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
   if (typeof type !== 'string'
   if (typeof type !== 'string'
     || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
     || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
     || typeof time !== 'number' || !Number.isSafeInteger(time)
     || typeof time !== 'number' || !Number.isSafeInteger(time)
-    || event['data'] === undefined) {
+    || event['data'] === undefined
+    || (event['ignorable'] !== undefined && event['ignorable'] !== true)) {
     throw new Error(`seed event at index ${index} has an invalid event envelope`)
     throw new Error(`seed event at index ${index} has an invalid event envelope`)
   }
   }
   switch (type) {
   switch (type) {

+ 9 - 5
packages/core/session/src/known-event-types.ts

@@ -8,12 +8,16 @@
 /**
 /**
  * Every `SessionEventMap` member declared in this repository — the event
  * Every `SessionEventMap` member declared in this repository — the event
  * vocabulary this build understands. The persistence read path refuses to
  * vocabulary this build understands. The persistence read path refuses to
- * interpret a log containing a type outside this set: such a log was likely
- * written by a newer harness, and silently skipping the event could
- * reconstruct a wrong session.
+ * interpret a log containing a type outside this set unless the event
+ * carries the envelope's `ignorable` marker (see `SessionEvent.ignorable`
+ * in `./types.ts`): such a log was likely written by a newer harness, and
+ * silently skipping a required event would reconstruct a wrong session.
  * Downstream (out-of-repo) plugin events are outside this list by
  * Downstream (out-of-repo) plugin events are outside this list by
- * construction; a registration surface for them is deferred until such a
- * consumer exists.
+ * construction. The persisted `SessionEvent.ignorable` marker is the
+ * compatibility mechanism; event-name registration was rejected because
+ * it does not classify omission safety and would make reads
+ * composition-dependent. The rationale is in
+ * `.agents/notes/implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md`.
  */
  */
 export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
 export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
   'agent-preset/selected',
   'agent-preset/selected',

+ 17 - 6
packages/core/session/src/types.ts

@@ -40,13 +40,13 @@ export function SessionId(id: string): SessionId {
  * wrong read). Only structural changes reach that bar: the header shape, the
  * wrong read). Only structural changes reach that bar: the header shape, the
  * {@link SessionEvent} envelope, core event semantics, or the surface
  * {@link SessionEvent} envelope, core event semantics, or the surface
  * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
  * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
- * Adding an ordinary event type does not bump: the generated known-event guard
- * makes older runtimes refuse logs containing a type they do not understand.
- * When in doubt, bump: a near-identity upgrade step is almost free, a missed
- * bump makes older runtimes read new logs wrong silently. The full mechanism
+ * Adding an ordinary event type does not bump — the per-event
+ * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When
+ * in doubt, bump: a near-identity upgrade step is almost free, a missed bump
+ * makes older runtimes read new logs wrong silently. The full mechanism
  * (upgrade-step chain, in-memory view conversion, migrate-on-continue) is
  * (upgrade-step chain, in-memory view conversion, migrate-on-continue) is
- * recorded in the fail-closed-session-event-vocabulary Agent Note
- * (`.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md`).
+ * recorded in the session-log-version-mechanism Agent Note
+ * (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`).
  */
  */
 export const SESSION_FORMAT_VERSION = 0
 export const SESSION_FORMAT_VERSION = 0
 
 
@@ -396,6 +396,17 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
     /** Unix epoch milliseconds. */
     /** Unix epoch milliseconds. */
     time: number
     time: number
     data: SessionEventMap[K]
     data: SessionEventMap[K]
+    /**
+     * Marks an event a reader may safely skip when it does not recognize
+     * `type`. Absent means required: a reader meeting an unrecognized type
+     * without this marker MUST refuse to reconstruct the session instead of
+     * silently dropping the event, because an unrecognized required event may
+     * change how the rest of the log is interpreted. A writer sets `true` only
+     * on purely informational records whose loss cannot affect reconstruction;
+     * defaulting to required means a forgotten marker over-refuses (an
+     * inconvenience) rather than silently resuming a gutted session.
+     */
+    ignorable?: true
   } & (K extends SurfaceEventType ? {
   } & (K extends SurfaceEventType ? {
     /**
     /**
      * Seq numbers of earlier events that this event cites as sources
      * Seq numbers of earlier events that this event cites as sources

+ 8 - 0
packages/core/session/tests/session.spec.ts

@@ -1073,12 +1073,20 @@ describe('Session', () => {
       { ...base, time: '1' },
       { ...base, time: '1' },
       { ...base, time: 0.5 },
       { ...base, time: 0.5 },
       { type: base.type, seq: base.seq, time: base.time },
       { type: base.type, seq: base.seq, time: base.time },
+      { ...base, ignorable: false },
+      { ...base, ignorable: 'yes' },
     ]
     ]
 
 
     for (const [index, event] of cases.entries()) {
     for (const [index, event] of cases.entries()) {
       expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
       expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
         .toThrow(/invalid event envelope/)
         .toThrow(/invalid event envelope/)
     }
     }
+
+    // `ignorable: true` is the one accepted marker value (unknown-type skip contract).
+    const marked = Session.create(SessionId('ignorable-envelope'), [
+      { ...base, ignorable: true } as SessionEvent,
+    ])
+    expect(marked.events[0]?.ignorable).toBe(true)
   })
   })
 })
 })
 
 

+ 1 - 0
packages/experimental/webworker-runtime/tests/vfs-example-fixture.ts

@@ -72,6 +72,7 @@ interface EventDraft {
   readonly data: unknown
   readonly data: unknown
   readonly surfaceOp?: 'append'
   readonly surfaceOp?: 'append'
   readonly sourceEventSeqs?: number[]
   readonly sourceEventSeqs?: number[]
+  readonly ignorable?: true
 }
 }
 
 
 class EventLog {
 class EventLog {

+ 2 - 2
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -4840,7 +4840,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   },
   {
   {
     name: 'SessionEvent',
     name: 'SessionEvent',
-    declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n    [K in SessionEventType]: {\n        type: K;\n        seq: number;\n        time: number;\n        data: SessionEventMap[K];\n    } & (K extends SurfaceEventType ? {\n        sourceEventSeqs?: number[];\n        surfaceOp?: SurfaceOp;\n    } : object);\n}[T];',
+    declaration: 'export type SessionEvent<T extends SessionEventType = SessionEventType> = {\n    [K in SessionEventType]: {\n        type: K;\n        seq: number;\n        time: number;\n        data: SessionEventMap[K];\n        ignorable?: true;\n    } & (K extends SurfaceEventType ? {\n        sourceEventSeqs?: number[];\n        surfaceOp?: SurfaceOp;\n    } : object);\n}[T];',
   },
   },
   {
   {
     name: 'SessionEventEntry',
     name: 'SessionEventEntry',
@@ -5204,7 +5204,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   },
   {
   {
     name: 'SessionWireEvent',
     name: 'SessionWireEvent',
-    declaration: 'export interface SessionWireEvent {\n    readonly type: string;\n    readonly seq: number;\n    readonly time: number;\n    readonly data: JsonValue;\n    readonly sourceEventSeqs?: number[];\n    readonly surfaceOp?: SurfaceOp;\n}',
+    declaration: 'export interface SessionWireEvent {\n    readonly type: string;\n    readonly seq: number;\n    readonly time: number;\n    readonly data: JsonValue;\n    readonly ignorable?: true;\n    readonly sourceEventSeqs?: number[];\n    readonly surfaceOp?: SurfaceOp;\n}',
   },
   },
   {
   {
     name: 'SettingsApplies',
     name: 'SettingsApplies',

+ 2 - 2
packages/session/session-persistence-sqlite/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-persistence-sqlite/README.md
 #   pnpm run verify-translation-pairing --write packages/session/session-persistence-sqlite/README.md
-README.md: fc8e8eb7032eda475fceb065b80315421020a63f
-README.zh.md: 88858ea07ff6bee54d29b8af947e610dc3879901
+README.md: ef3aea6ceeebafa228c94a5821cec576eca2c7dd
+README.zh.md: d3652eeef934f077b91769ab296ba26187935ac2

+ 7 - 7
packages/session/session-persistence-sqlite/README.md

@@ -33,7 +33,7 @@ Choose this backend when a local deployment benefits from one queryable database
 
 
 ### Disk footprint and performance
 ### Disk footprint and performance
 
 
-The packed layout exchanges some SQLite-local latency for a smaller queryable database. On the 501-session comparison corpus, the schema-19 layout used 233.18 MB against the SQLite comparison baseline's 438.31 MB and compressed JSONL's 148.15 MB. Full writes were about 2.3× faster than JSONL and suffix reads remained much faster; complete reads and forks were slightly slower than JSONL. The [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md) owns the method, complete metrics, and accepted trade-offs.
+The packed layout exchanges some SQLite-local latency for a smaller queryable database. The available 501-session comparison measures schema 19 rather than schema 20; that layout used 233.18 MB against the SQLite comparison baseline's 438.31 MB and compressed JSONL's 148.15 MB. Full writes were about 2.3× faster than JSONL and suffix reads remained much faster; complete reads and forks were slightly slower than JSONL. The [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md) owns the method, complete metrics, and accepted trade-offs.
 
 
 The disk cost buys a structured, queryable view of session history: external tooling can analyze `sessions` and `events` with SQL, decoding physical rows the way this provider does — the groundwork for features such as built-in full-text search.
 The disk cost buys a structured, queryable view of session history: external tooling can analyze `sessions` and `events` with SQL, decoding physical rows the way this provider does — the groundwork for features such as built-in full-text search.
 
 
@@ -75,7 +75,7 @@ await ctx.sessionPersistence.append(id, events)
 
 
 ### Startup and safe operation
 ### Startup and safe operation
 
 
-A fresh database initializes directly at schema version 19 with 64 KiB pages. Existing files are never retuned: databases with any other version, a foreign application identity, an unversioned non-pristine schema, or unexpected schema objects are rejected before any data is exposed or changed. This pre-release provider ships no migration. Every statement and fixed pragma comes from packaged `.sql` resources in `resources/sql/`, and runtime values are bound as SQLite parameters, so package code never assembles query text.
+A fresh database initializes directly at schema version 20 with 64 KiB pages. Existing files are never retuned: databases with any other version, a foreign application identity, an unversioned non-pristine schema, or unexpected schema objects are rejected before any data is exposed or changed. This pre-release provider ships no migration. Every statement and fixed pragma comes from packaged `.sql` resources in `resources/sql/`, and runtime values are bound as SQLite parameters, so package code never assembles query text.
 
 
 Each connection disables SQLite trusted schemas and memory-mapped I/O, verifies the requested journal mode, and pins `synchronous=FULL` so a resolved append remains durable across an OS crash or power loss. On POSIX, the database parent directory and file must belong to the current user, the parent must not be group/world-writable, and the file must grant no group or world permissions; Windows additionally rejects symbolic links and non-regular files, while ACL restriction stays the deployment's job. Path and ownership failures reject plugin initialization; Node's SQLite driver loads lazily on the first persistence operation. Ordinary `create` stays lazy until the first append, while `ensureMaterialized` writes a session metadata row with no event rows.
 Each connection disables SQLite trusted schemas and memory-mapped I/O, verifies the requested journal mode, and pins `synchronous=FULL` so a resolved append remains durable across an OS crash or power loss. On POSIX, the database parent directory and file must belong to the current user, the parent must not be group/world-writable, and the file must grant no group or world permissions; Windows additionally rejects symbolic links and non-regular files, while ACL restriction stays the deployment's job. Path and ownership failures reject plugin initialization; Node's SQLite driver loads lazily on the first persistence operation. Ordinary `create` stays lazy until the first append, while `ensureMaterialized` writes a session metadata row with no event rows.
 
 
@@ -94,7 +94,7 @@ This section explains the design decisions behind the provider and points at the
 The provider is built on one separation and three commitments:
 The provider is built on one separation and three commitments:
 
 
 - **Logical contract, physical format.** Callers always read and write ordinary `SessionEvent[]`; how rows are packed, stored, and compressed is private to this package.
 - **Logical contract, physical format.** Callers always read and write ordinary `SessionEvent[]`; how rows are packed, stored, and compressed is private to this package.
-- **The schema owns the format.** Schema 19 is a frozen physical contract: a database at another version, with a foreign identity, or with unexpected schema objects is rejected, never migrated. Changing the schema, row codec, page size, or dictionary bytes requires a new schema version.
+- **The schema owns the format.** Schema 20 is a frozen physical contract: a database at another version, with a foreign identity, or with unexpected schema objects is rejected, never migrated. Changing the schema, row codec, page size, or dictionary bytes requires a new schema version.
 - **Durability is the default.** Appends run in immediate transactions with `synchronous=FULL`, and a resolved `append()` means the batch is durable. Normal appends are insert-only: earlier event rows are never rewritten.
 - **Durability is the default.** Appends run in immediate transactions with `synchronous=FULL`, and a resolved `append()` means the batch is durable. Normal appends are insert-only: earlier event rows are never rewritten.
 - **Efficiency within strict bounds.** Packing and compression keep the database small, but every limit is a hard format bound — at most 1,024 events and 1 MiB of payload per packed row.
 - **Efficiency within strict bounds.** Packing and compression keep the database small, but every limit is a hard format bound — at most 1,024 events and 1 MiB of payload per packed row.
 
 
@@ -122,7 +122,7 @@ A fresh database contains three strict tables, defined in [`resources/sql/schema
 | `sessions` | One row per session: header fields plus a monotonic revision |
 | `sessions` | One row per session: header fields plus a monotonic revision |
 | `events` | Physical event rows: one logical event, or one packed run |
 | `events` | Physical event rows: one logical event, or one packed run |
 
 
-The exact columns live in [`resources/sql/schema.sql`](resources/sql/schema.sql). `sessions.id` is an internal integer key while `sessions.session_key` retains the public session id. `events.data` holds text or an independently decodable Zstandard blob; compression uses the schema-owned shared dictionary only when the result is smaller. `events.source_event_seqs` uses tagged delta or run encoding. `events.is_packed` is `0` for a scalar logical event and `1` for a packed chunk run, so a scalar event whose type matches a physical chunk tag remains unambiguous. Packed rows reuse the `seq` of their first logical event, so under the composite `(session_id, seq)` primary key physical order is logical order.
+The exact columns live in [`resources/sql/schema.sql`](resources/sql/schema.sql). `sessions.id` is an internal integer key while `sessions.session_key` retains the public session id. `events.data` holds text or an independently decodable Zstandard blob; compression uses the schema-owned shared dictionary only when the result is smaller. `events.source_event_seqs` uses tagged delta or run encoding. `events.ignorable` is `0` for a packed chunk run, `1` for a scalar logical event carrying `ignorable: true`, and `NULL` for every other scalar event, so a scalar event whose type matches a physical chunk tag remains unambiguous. Packed rows reuse the `seq` of their first logical event, so under the composite `(session_id, seq)` primary key physical order is logical order.
 
 
 ### Write path
 ### Write path
 
 
@@ -145,7 +145,7 @@ Read these pages when the package-level contract is not enough. They move from t
 - [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
 - [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
 - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-persistence-sqlite) — every accepted config field and its source declaration.
 - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-persistence-sqlite) — every accepted config field and its source declaration.
 - [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) — rationale, alternatives, and measurements behind the packed layout.
 - [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) — rationale, alternatives, and measurements behind the packed layout.
-- [Persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md) — the 501-session benchmark and schema-19 storage trade-offs.
+- [Persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md) — the 501-session benchmark and current storage trade-offs.
 
 
 -----
 -----
 
 
@@ -173,7 +173,7 @@ Physical packing does not mutate request prefixes. Provider cache reuse depends
 
 
 These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general SQLite comparison or a task backlog.
 These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general SQLite comparison or a task backlog.
 
 
-- **Pre-release design with no migration** — schema 19 is an interim SQLite-only design; neither schema stability nor migration support is guaranteed.
+- **Pre-release design with no migration** — schema 20 is an interim SQLite-only design; neither schema stability nor migration support is guaranteed.
 - **Packing depends on batch boundaries** — a compatible run split by the write-behind window or an explicit flush stays split across physical rows; this avoids rewriting prior rows at the cost of a timing-dependent packing ratio.
 - **Packing depends on batch boundaries** — a compatible run split by the write-behind window or an explicit flush stays split across physical rows; this avoids rewriting prior rows at the cost of a timing-dependent packing ratio.
 - **Synchronous SQLite and compression** — Node's SQLite driver and Zstandard calls block the JavaScript thread.
 - **Synchronous SQLite and compression** — Node's SQLite driver and Zstandard calls block the JavaScript thread.
 - **Busy waits block the event loop** — SQLite waits inside synchronous calls; a competing writer can stall the thread for up to the configured `busyTimeoutMs`.
 - **Busy waits block the event loop** — SQLite waits inside synchronous calls; a competing writer can stall the thread for up to the configured `busyTimeoutMs`.
@@ -186,6 +186,6 @@ These limits define when the provider is a poor fit or needs special operational
 <details>
 <details>
 <summary>Working context for maintainers — click to expand</summary>
 <summary>Working context for maintainers — click to expand</summary>
 
 
-The 501-session corpus contains private session data and is not committed. Its aggregate method, complete results, and rejected candidates are recorded in the [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md); the packaged dictionary's hash-pinned resource is the schema-19 source of truth.
+The 501-session corpus contains private session data and is not committed. Its aggregate method, complete results, and rejected candidates are recorded in the [persistence latency and page-size decision](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.md); the packaged dictionary's hash-pinned resource is part of the schema-20 source of truth.
 
 
 </details>
 </details>

+ 7 - 7
packages/session/session-persistence-sqlite/README.zh.md

@@ -33,7 +33,7 @@ kind: "package-reference"
 
 
 ### 磁盘占用与性能
 ### 磁盘占用与性能
 
 
-打包布局以部分 SQLite 本地延迟换取更小的可查询数据库。在 501 会话对比语料上,schema-19 布局占用 233.18 MB,SQLite 对比基线占用 438.31 MB,压缩 JSONL 占用 148.15 MB。全量写入约比 JSONL 快 2.3 倍,后缀读取也仍快得多;完整读取与 fork 则略慢于 JSONL。方法、完整指标与取舍由[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)记录。
+打包布局以部分 SQLite 本地延迟换取更小的可查询数据库。现有的 501 会话对比测量的是 schema 19,而不是 schema 20;该布局占用 233.18 MB,SQLite 对比基线占用 438.31 MB,压缩 JSONL 占用 148.15 MB。全量写入约比 JSONL 快 2.3 倍,后缀读取也仍快得多;完整读取与 fork 则略慢于 JSONL。方法、完整指标与取舍由[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)记录。
 
 
 磁盘成本换来的是结构化、可查询的会话历史视图:外部工具可以用 SQL 分析 `sessions` 与 `events`,按本提供方的方式解码物理行——这是内置全文搜索等功能的天然基础。
 磁盘成本换来的是结构化、可查询的会话历史视图:外部工具可以用 SQL 分析 `sessions` 与 `events`,按本提供方的方式解码物理行——这是内置全文搜索等功能的天然基础。
 
 
@@ -75,7 +75,7 @@ await ctx.sessionPersistence.append(id, events)
 
 
 ### 启动与安全运行
 ### 启动与安全运行
 
 
-全新数据库直接初始化为 schema 版本 19,并使用 64 KiB page。已有文件不会被重新调参:任何其他版本、外来应用标识、无版本的非全新 schema 或意外 schema 对象,都会在任何数据暴露或变更之前被拒绝。本预发布提供方不提供迁移。每条语句和固定 pragma 都来自 `resources/sql/` 下打包的 `.sql` 资源,运行时的值以 SQLite 参数绑定,包代码从不拼装查询文本。
+全新数据库直接初始化为 schema 版本 20,并使用 64 KiB page。已有文件不会被重新调参:任何其他版本、外来应用标识、无版本的非全新 schema 或意外 schema 对象,都会在任何数据暴露或变更之前被拒绝。本预发布提供方不提供迁移。每条语句和固定 pragma 都来自 `resources/sql/` 下打包的 `.sql` 资源,运行时的值以 SQLite 参数绑定,包代码从不拼装查询文本。
 
 
 每个连接都会禁用 SQLite trusted schema 与内存映射 I/O、验证所请求的 journal mode,并固定 `synchronous=FULL`,保证成功返回的追加在操作系统崩溃或断电后依然持久。在 POSIX 上,数据库父目录和文件必须属于当前用户,父目录不得允许组或其他用户写入,文件也不得授予任何组或其他用户权限;Windows 还会拒绝符号链接和非普通文件,ACL 限制则由部署方负责。路径与所有权失败会拒绝插件初始化;Node 的 SQLite 驱动在首次持久化操作时才延迟加载。普通 `create` 会保持惰性直到首次 append,而 `ensureMaterialized` 会写入一条没有事件行的会话元数据记录。
 每个连接都会禁用 SQLite trusted schema 与内存映射 I/O、验证所请求的 journal mode,并固定 `synchronous=FULL`,保证成功返回的追加在操作系统崩溃或断电后依然持久。在 POSIX 上,数据库父目录和文件必须属于当前用户,父目录不得允许组或其他用户写入,文件也不得授予任何组或其他用户权限;Windows 还会拒绝符号链接和非普通文件,ACL 限制则由部署方负责。路径与所有权失败会拒绝插件初始化;Node 的 SQLite 驱动在首次持久化操作时才延迟加载。普通 `create` 会保持惰性直到首次 append,而 `ensureMaterialized` 会写入一条没有事件行的会话元数据记录。
 
 
@@ -94,7 +94,7 @@ await ctx.sessionPersistence.append(id, events)
 本提供方建立在一个分离与三项承诺之上:
 本提供方建立在一个分离与三项承诺之上:
 
 
 - **逻辑约定,物理格式。** 调用方始终读写普通的 `SessionEvent[]`;行如何打包、存储与压缩是本包私有的存储行为。
 - **逻辑约定,物理格式。** 调用方始终读写普通的 `SessionEvent[]`;行如何打包、存储与压缩是本包私有的存储行为。
-- **schema 拥有格式。** Schema 19 是冻结的物理约定:任何其他版本、外来标识或意外 schema 对象的数据库都会被拒绝,绝不迁移。改变 schema、行 codec、page size 或字典字节都需要新的 schema 版本。
+- **schema 拥有格式。** Schema 20 是冻结的物理约定:任何其他版本、外来标识或意外 schema 对象的数据库都会被拒绝,绝不迁移。改变 schema、行 codec、page size 或字典字节都需要新的 schema 版本。
 - **持久性是默认值。** 追加在立即事务中以 `synchronous=FULL` 提交,成功返回的 `append()` 意味着该批次已持久。普通追加仅插入:更早的事件行永远不会被重写。
 - **持久性是默认值。** 追加在立即事务中以 `synchronous=FULL` 提交,成功返回的 `append()` 意味着该批次已持久。普通追加仅插入:更早的事件行永远不会被重写。
 - **在严格边界内追求效率。** 打包与压缩让数据库保持小巧,但每个上限都是硬性格式边界——每个打包行至多表示 1,024 个事件、1 MiB 载荷。
 - **在严格边界内追求效率。** 打包与压缩让数据库保持小巧,但每个上限都是硬性格式边界——每个打包行至多表示 1,024 个事件、1 MiB 载荷。
 
 
@@ -122,7 +122,7 @@ await ctx.sessionPersistence.append(id, events)
 | `sessions` | 每个会话一行:头部字段加单调递增的 revision |
 | `sessions` | 每个会话一行:头部字段加单调递增的 revision |
 | `events` | 物理事件行:一个逻辑事件,或一个打包连续段 |
 | `events` | 物理事件行:一个逻辑事件,或一个打包连续段 |
 
 
-确切的列定义见 [`resources/sql/schema.sql`](resources/sql/schema.sql)。`sessions.id` 是内部整数键,`sessions.session_key` 保留公开会话 id。`events.data` 存放文本或可独立解码的 Zstandard blob;仅在结果更小时才使用 schema 自有的共享字典压缩。`events.source_event_seqs` 使用带 tag 的 delta 或 run 编码。标量逻辑事件的 `events.is_packed` 为 `0`,打包分片连续段的该值为 `1`,因此类型与物理分片标签同名的标量事件仍然明确。打包行沿用其首个逻辑事件的 `seq`,因此在复合主键 `(session_id, seq)` 下,物理顺序就是逻辑顺序。
+确切的列定义见 [`resources/sql/schema.sql`](resources/sql/schema.sql)。`sessions.id` 是内部整数键,`sessions.session_key` 保留公开会话 id。`events.data` 存放文本或可独立解码的 Zstandard blob;仅在结果更小时才使用 schema 自有的共享字典压缩。`events.source_event_seqs` 使用带 tag 的 delta 或 run 编码。打包分片连续段的 `events.ignorable` 为 `0`,带 `ignorable: true` 的标量逻辑事件为 `1`,其余标量事件为 `NULL`,因此类型与物理分片标签同名的标量事件仍然明确。打包行沿用其首个逻辑事件的 `seq`,因此在复合主键 `(session_id, seq)` 下,物理顺序就是逻辑顺序。
 
 
 ### 写入路径
 ### 写入路径
 
 
@@ -145,7 +145,7 @@ await ctx.sessionPersistence.append(id, events)
 - [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
 - [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-persistence-sqlite)——每个受支持配置字段及其源声明。
 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-persistence-sqlite)——每个受支持配置字段及其源声明。
 - [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)——打包布局背后的理由、备选方案与测量。
 - [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)——打包布局背后的理由、备选方案与测量。
-- [持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)——501 会话基准与 schema-19 存储取舍。
+- [持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)——501 会话基准与当前存储取舍。
 
 
 -----
 -----
 
 
@@ -173,7 +173,7 @@ await ctx.sessionPersistence.append(id, events)
 
 
 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用 SQLite 对比或任务积压。
 这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用 SQLite 对比或任务积压。
 
 
-- **预发布设计,无迁移**——schema 19 是临时的 SQLite 专用设计;不保证 schema 稳定性或迁移支持。
+- **预发布设计,无迁移**——schema 20 是临时的 SQLite 专用设计;不保证 schema 稳定性或迁移支持。
 - **打包依赖批次边界**——被写后窗口或显式 flush 拆开的兼容连续段仍分属不同物理行;这避免了重写先前行,代价是打包比例依赖时序。
 - **打包依赖批次边界**——被写后窗口或显式 flush 拆开的兼容连续段仍分属不同物理行;这避免了重写先前行,代价是打包比例依赖时序。
 - **同步 SQLite 与压缩**——Node 的 SQLite 驱动与 Zstandard 调用会阻塞 JavaScript 线程。
 - **同步 SQLite 与压缩**——Node 的 SQLite 驱动与 Zstandard 调用会阻塞 JavaScript 线程。
 - **忙等待阻塞事件循环**——SQLite 在同步调用内部等待;竞争写入方最长可让线程停顿配置的 `busyTimeoutMs`。
 - **忙等待阻塞事件循环**——SQLite 在同步调用内部等待;竞争写入方最长可让线程停顿配置的 `busyTimeoutMs`。
@@ -186,6 +186,6 @@ await ctx.sessionPersistence.append(id, events)
 <details>
 <details>
 <summary>维护者的工作上下文——点击展开</summary>
 <summary>维护者的工作上下文——点击展开</summary>
 
 
-501 会话语料包含私有会话数据,因此不提交到仓库。汇总方法、完整结果与未采用候选记录在[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)中;schema 19 以打包资源及测试固定的字典摘要为准。
+501 会话语料包含私有会话数据,因此不提交到仓库。汇总方法、完整结果与未采用候选记录在[持久化延迟与 page size 决策](../../../.agents/notes/implemented/architecture/2026-08-25-persistence-latency-and-page-size.zh.md)中;带 hash 固定的打包字典资源是 schema 20 真源的一部分。
 
 
 </details>
 </details>

+ 1 - 1
packages/session/session-persistence-sqlite/resources/sql/insert-event.sql

@@ -1,3 +1,3 @@
 INSERT INTO events
 INSERT INTO events
-  (session_id, seq, type, time, data, source_event_seqs, surface_op, is_packed)
+  (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable)
 VALUES (?, ?, ?, ?, ?, ?, ?, ?);
 VALUES (?, ?, ?, ?, ?, ?, ?, ?);

+ 1 - 1
packages/session/session-persistence-sqlite/resources/sql/schema.sql

@@ -26,6 +26,6 @@ CREATE TABLE events (
   data              ANY NOT NULL,
   data              ANY NOT NULL,
   source_event_seqs ANY,
   source_event_seqs ANY,
   surface_op        TEXT,
   surface_op        TEXT,
-  is_packed         INTEGER NOT NULL CHECK (is_packed IN (0, 1)),
+  ignorable         INTEGER CHECK (ignorable IS NULL OR ignorable IN (0, 1)),
   PRIMARY KEY (session_id, seq)
   PRIMARY KEY (session_id, seq)
 ) STRICT;
 ) STRICT;

+ 1 - 1
packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql

@@ -1,4 +1,4 @@
-SELECT seq, type, time, data, source_event_seqs, surface_op, is_packed
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
 FROM events
 FROM events
 WHERE session_id = ? AND seq >= ?
 WHERE session_id = ? AND seq >= ?
 ORDER BY seq;
 ORDER BY seq;

+ 1 - 1
packages/session/session-persistence-sqlite/resources/sql/select-events.sql

@@ -1,4 +1,4 @@
-SELECT seq, type, time, data, source_event_seqs, surface_op, is_packed
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
 FROM events
 FROM events
 WHERE session_id = ?
 WHERE session_id = ?
 ORDER BY seq;
 ORDER BY seq;

+ 2 - 2
packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql

@@ -1,6 +1,6 @@
-SELECT seq, type, time, data, source_event_seqs, surface_op, is_packed
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
 FROM events
 FROM events
 WHERE session_id = ? AND seq >= ? AND seq < ?
 WHERE session_id = ? AND seq >= ? AND seq < ?
   AND type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
   AND type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
-  AND is_packed = 1
+  AND ignorable = 0
 ORDER BY seq;
 ORDER BY seq;

+ 1 - 1
packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql

@@ -1,4 +1,4 @@
-SELECT seq, type, time, data, source_event_seqs, surface_op, is_packed
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
 FROM events
 FROM events
 WHERE session_id = ?
 WHERE session_id = ?
 ORDER BY seq DESC
 ORDER BY seq DESC

+ 0 - 1
packages/session/session-persistence-sqlite/resources/sql/set-user-version-19.sql

@@ -1 +0,0 @@
-PRAGMA user_version = 19;

+ 1 - 0
packages/session/session-persistence-sqlite/resources/sql/set-user-version-20.sql

@@ -0,0 +1 @@
+PRAGMA user_version = 20;

+ 6 - 6
packages/session/session-persistence-sqlite/src/codec.ts

@@ -1,5 +1,5 @@
 /**
 /**
- * Schema-19 physical chunk-row codec. This package owns the durable tags,
+ * Schema-20 physical chunk-row codec. This package owns the durable tags,
  * validation, and row-size limits independently from other persistence formats.
  * validation, and row-size limits independently from other persistence formats.
  * @module @deepseek-ai/dsh-session-persistence-sqlite/codec
  * @module @deepseek-ai/dsh-session-persistence-sqlite/codec
  */
  */
@@ -7,7 +7,7 @@
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { StreamChunk } from '@deepseek-ai/dsh-llm'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 
 
-/* jscpd:ignore-start -- schema 19 deliberately owns a frozen physical codec;
+/* jscpd:ignore-start -- schema 20 deliberately owns a frozen physical codec;
  * importing or sharing the JSONL codec would let that format mutate this database interpreter. */
  * importing or sharing the JSONL codec would let that format mutate this database interpreter. */
 type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
 type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
 type DeltaEvent = SessionEvent<'assistant/chunk'>
 type DeltaEvent = SessionEvent<'assistant/chunk'>
@@ -29,13 +29,13 @@ interface ToolCallRunData extends RunDataBase {
   readonly args: string[]
   readonly args: string[]
 }
 }
 
 
-/** One schema-19 packed physical record. */
+/** One schema-20 packed physical record. */
 export type ChunkRow =
 export type ChunkRow =
   | { readonly type: 'text-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData }
   | { readonly type: 'text-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData }
   | { readonly type: 'reasoning-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData }
   | { readonly type: 'reasoning-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData }
   | { readonly type: 'tool-call-chunks'; readonly seq0: number; readonly time0: number; readonly data: ToolCallRunData }
   | { readonly type: 'tool-call-chunks'; readonly seq0: number; readonly time0: number; readonly data: ToolCallRunData }
 
 
-/** One scalar event or schema-19 packed physical record. */
+/** One scalar event or schema-20 packed physical record. */
 export type StorageRecord = SessionEvent | ChunkRow
 export type StorageRecord = SessionEvent | ChunkRow
 
 
 /** Minimum eligible members in a packed physical record. */
 /** Minimum eligible members in a packed physical record. */
@@ -174,7 +174,7 @@ function emitBoundedRun(out: StorageRecord[], kind: DeltaKind, completeRun: read
 }
 }
 
 
 /**
 /**
- * Pack eligible logical chunk runs into bounded schema-19 records.
+ * Pack eligible logical chunk runs into bounded schema-20 records.
  * @param events - logical events in sequence order.
  * @param events - logical events in sequence order.
  * @returns scalar and packed physical records in equivalent order.
  * @returns scalar and packed physical records in equivalent order.
  */
  */
@@ -308,7 +308,7 @@ function expandRow(row: ChunkRow): SessionEvent[] {
 }
 }
 
 
 /**
 /**
- * Decode one scalar or packed schema-19 record.
+ * Decode one scalar or packed schema-20 record.
  * @param value - parsed physical-record value.
  * @param value - parsed physical-record value.
  * @returns the represented logical events.
  * @returns the represented logical events.
  */
  */

+ 7 - 5
packages/session/session-persistence-sqlite/src/compression.ts

@@ -25,7 +25,7 @@ export interface BoundRecord {
   readonly data: string | Uint8Array
   readonly data: string | Uint8Array
   readonly sourceEventSeqs: Uint8Array | null
   readonly sourceEventSeqs: Uint8Array | null
   readonly surfaceOp: string | null
   readonly surfaceOp: string | null
-  readonly isPacked: 0 | 1
+  readonly ignorable: number | null
 }
 }
 
 
 const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
 const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
@@ -34,8 +34,9 @@ const DELTA_TAG = 0
 const RUN_TAG = 1
 const RUN_TAG = 1
 const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER)
 const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER)
 const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n
 const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n
+const PACKED_ROW_SENTINEL = 0
 /**
 /**
- * Schema-19 raw-content zstd dictionary for independently decodable data rows.
+ * Schema-20 raw-content zstd dictionary for independently decodable data rows.
  * Its exact bytes are part of the physical format; changing the resource
  * Its exact bytes are part of the physical format; changing the resource
  * requires a schema-version bump.
  * requires a schema-version bump.
  */
  */
@@ -59,7 +60,7 @@ function isChunkTag(value: string): value is ChunkTag {
  * @returns every logical event represented by the row.
  * @returns every logical event represented by the row.
  */
  */
 export function decodeRow(row: EventRow): SessionEvent[] {
 export function decodeRow(row: EventRow): SessionEvent[] {
-  if (row.is_packed === 0) return [decodeScalarRow(row)]
+  if (row.ignorable !== PACKED_ROW_SENTINEL) return [decodeScalarRow(row)]
   if (!isChunkTag(row.type)) {
   if (!isChunkTag(row.type)) {
     throw new Error(`malformed ${row.type} storage row: packed discriminator requires a chunk tag`)
     throw new Error(`malformed ${row.type} storage row: packed discriminator requires a chunk tag`)
   }
   }
@@ -88,7 +89,7 @@ export function bindRecord(record: StorageRecord): BoundRecord {
       data: encodeData(JSON.stringify(record.data)),
       data: encodeData(JSON.stringify(record.data)),
       sourceEventSeqs: null,
       sourceEventSeqs: null,
       surfaceOp: null,
       surfaceOp: null,
-      isPacked: 1,
+      ignorable: PACKED_ROW_SENTINEL,
     }
     }
   }
   }
   const event = record
   const event = record
@@ -102,7 +103,7 @@ export function bindRecord(record: StorageRecord): BoundRecord {
       ? null
       ? null
       : encodeSourceEventSeqs(surface.sourceEventSeqs),
       : encodeSourceEventSeqs(surface.sourceEventSeqs),
     surfaceOp: surface.surfaceOp === undefined ? null : JSON.stringify(surface.surfaceOp),
     surfaceOp: surface.surfaceOp === undefined ? null : JSON.stringify(surface.surfaceOp),
-    isPacked: 0,
+    ignorable: event.ignorable === true ? 1 : null,
   }
   }
 }
 }
 
 
@@ -277,6 +278,7 @@ function decodeScalarRow(row: EventRow): SessionEvent {
     time: row.time,
     time: row.time,
     data: JSON.parse(decodeData(row.data)) as SessionEvent['data'],
     data: JSON.parse(decodeData(row.data)) as SessionEvent['data'],
     ...surfaceFields,
     ...surfaceFields,
+    ...row.ignorable === 1 ? { ignorable: true as const } : {},
   } as SessionEvent
   } as SessionEvent
 }
 }
 
 

+ 1 - 1
packages/session/session-persistence-sqlite/src/index.ts

@@ -1,6 +1,6 @@
 /**
 /**
  * Opt-in SQLite persistence provider. Logical sessions remain unchanged;
  * Opt-in SQLite persistence provider. Logical sessions remain unchanged;
- * the physical backend packs eligible chunk runs into schema-19 rows.
+ * the physical backend packs eligible chunk runs into schema-20 rows.
  * @module @deepseek-ai/dsh-session-persistence-sqlite
  * @module @deepseek-ai/dsh-session-persistence-sqlite
  */
  */
 
 

+ 7 - 7
packages/session/session-persistence-sqlite/src/schema.ts

@@ -16,7 +16,7 @@ import {
 import { sql } from './sql.ts'
 import { sql } from './sql.ts'
 
 
 /** Current physical-record schema with packed and compressed event rows. */
 /** Current physical-record schema with packed and compressed event rows. */
-export const SCHEMA_VERSION = 19
+export const SCHEMA_VERSION = 20
 /** Application id reserved for DeepSeek Harness SQLite session databases. */
 /** Application id reserved for DeepSeek Harness SQLite session databases. */
 export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
 export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
 
 
@@ -43,7 +43,7 @@ export interface EventRow {
   readonly data: string | Uint8Array
   readonly data: string | Uint8Array
   readonly source_event_seqs: Uint8Array | null
   readonly source_event_seqs: Uint8Array | null
   readonly surface_op: string | null
   readonly surface_op: string | null
-  readonly is_packed: 0 | 1
+  readonly ignorable: number | null
 }
 }
 
 
 /** Durable journal modes accepted by the backend. */
 /** Durable journal modes accepted by the backend. */
@@ -208,7 +208,7 @@ function initializeDatabase(db: DatabaseSync): void {
   db.exec(sql('schema'))
   db.exec(sql('schema'))
   db.prepare(sql('insert-persistence-state')).run(randomUUID())
   db.prepare(sql('insert-persistence-state')).run(randomUUID())
   db.exec(sql('set-application-id'))
   db.exec(sql('set-application-id'))
-  db.exec(sql('set-user-version-19'))
+  db.exec(sql('set-user-version-20'))
 }
 }
 
 
 let canonicalSchema: readonly SchemaObjectRow[] | undefined
 let canonicalSchema: readonly SchemaObjectRow[] | undefined
@@ -315,9 +315,9 @@ export function decodeSessionRow(value: unknown): SessionRow {
  */
  */
 export function decodeEventRow(value: unknown): EventRow {
 export function decodeEventRow(value: unknown): EventRow {
   const row = record(value, 'stored event')
   const row = record(value, 'stored event')
-  const isPacked = safeIntegerField(row, 'is_packed')
-  if (isPacked !== 0 && isPacked !== 1) {
-    throw new Error('stored event is_packed must be 0 or 1')
+  const ignorable = nullableSafeIntegerField(row, 'ignorable')
+  if (ignorable !== null && ignorable !== 0 && ignorable !== 1) {
+    throw new Error('stored event ignorable must be 0, 1, or null')
   }
   }
   return {
   return {
     seq: nonnegativeSafeIntegerField(row, 'seq'),
     seq: nonnegativeSafeIntegerField(row, 'seq'),
@@ -326,7 +326,7 @@ export function decodeEventRow(value: unknown): EventRow {
     data: stringOrBlobField(row, 'data'),
     data: stringOrBlobField(row, 'data'),
     source_event_seqs: nullableBlobField(row, 'source_event_seqs'),
     source_event_seqs: nullableBlobField(row, 'source_event_seqs'),
     surface_op: nullableStringField(row, 'surface_op'),
     surface_op: nullableStringField(row, 'surface_op'),
-    is_packed: isPacked,
+    ignorable,
   }
   }
 }
 }
 
 

+ 1 - 1
packages/session/session-persistence-sqlite/src/sql.ts

@@ -38,7 +38,7 @@ const SQL_RESOURCES = [
   'select-user-object-count',
   'select-user-object-count',
   'select-user-version',
   'select-user-version',
   'set-application-id',
   'set-application-id',
-  'set-user-version-19',
+  'set-user-version-20',
   'synchronous-full',
   'synchronous-full',
   'trusted-schema-off',
   'trusted-schema-off',
   'update-session-revision',
   'update-session-revision',

+ 1 - 1
packages/session/session-persistence-sqlite/src/store.ts

@@ -383,7 +383,7 @@ export class SqliteStore implements PersistenceBackend<number> {
       record.data,
       record.data,
       record.sourceEventSeqs,
       record.sourceEventSeqs,
       record.surfaceOp,
       record.surfaceOp,
-      record.isPacked,
+      record.ignorable,
     )
     )
   }
   }
 
 

+ 11 - 10
packages/session/session-persistence-sqlite/tests/compression.spec.ts

@@ -44,12 +44,12 @@ function row(record: StorageRecord): EventRow {
     data: bound.data,
     data: bound.data,
     source_event_seqs: bound.sourceEventSeqs,
     source_event_seqs: bound.sourceEventSeqs,
     surface_op: bound.surfaceOp,
     surface_op: bound.surfaceOp,
-    is_packed: bound.isPacked,
+    ignorable: bound.ignorable,
   }
   }
 }
 }
 
 
 describe('SQLite compression', () => {
 describe('SQLite compression', () => {
-  it('pins the schema-19 dictionary bytes', () => {
+  it('pins the schema-20 dictionary bytes', () => {
     const dictionary = readFileSync(new URL('../resources/zstd-dictionary.bin', import.meta.url))
     const dictionary = readFileSync(new URL('../resources/zstd-dictionary.bin', import.meta.url))
     expect(createHash('sha256').update(dictionary).digest('hex'))
     expect(createHash('sha256').update(dictionary).digest('hex'))
       .toBe('dad18fa0247a8fdd886a62d8552eabd36cbd50c25af172873080d2f0ae770d17')
       .toBe('dad18fa0247a8fdd886a62d8552eabd36cbd50c25af172873080d2f0ae770d17')
@@ -167,7 +167,7 @@ describe('SQLite compression', () => {
     expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/)
     expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/)
   })
   })
 
 
-  it('decodes the schema-19 row vocabulary without another package codec', () => {
+  it('decodes the schema-20 row vocabulary without another package codec', () => {
     const fixture: EventRow = {
     const fixture: EventRow = {
       seq: 7,
       seq: 7,
       type: 'text-chunks',
       type: 'text-chunks',
@@ -175,7 +175,7 @@ describe('SQLite compression', () => {
       data: JSON.stringify({ turn: 2, step: 3, index: 1, dt: [2, -1], texts: ['a', 'b', 'c'] }),
       data: JSON.stringify({ turn: 2, step: 3, index: 1, dt: [2, -1], texts: ['a', 'b', 'c'] }),
       source_event_seqs: null,
       source_event_seqs: null,
       surface_op: null,
       surface_op: null,
-      is_packed: 1,
+      ignorable: 0,
     }
     }
     expect(decodeRow(fixture)).toEqual([
     expect(decodeRow(fixture)).toEqual([
       { ...chunk(7, 'a'), time: 90, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'a' } } },
       { ...chunk(7, 'a'), time: 90, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'a' } } },
@@ -199,21 +199,22 @@ describe('SQLite compression', () => {
 
 
   it('rejects the packed discriminator on a scalar event type', () => {
   it('rejects the packed discriminator on a scalar event type', () => {
     const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
     const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
-    expect(() => decodeRow({ ...scalar, is_packed: 1 }))
+    expect(() => decodeRow({ ...scalar, ignorable: 0 }))
       .toThrow(/packed discriminator requires a chunk tag/)
       .toThrow(/packed discriminator requires a chunk tag/)
   })
   })
 
 
   it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])(
   it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])(
-    'preserves a logical event named %s as a scalar row',
+    'preserves an ignorable logical event named %s as a scalar row',
     (type) => {
     (type) => {
       const logical = {
       const logical = {
         type,
         type,
         seq: 0,
         seq: 0,
         time: 1,
         time: 1,
         data: { future: true },
         data: { future: true },
+        ignorable: true,
       } as unknown as SessionEvent
       } as unknown as SessionEvent
       const physical = row(logical)
       const physical = row(logical)
-      expect(physical.is_packed).toBe(0)
+      expect(physical.ignorable).toBe(1)
       expect(decodeRow(physical)).toEqual([logical])
       expect(decodeRow(physical)).toEqual([logical])
     },
     },
   )
   )
@@ -339,7 +340,7 @@ describe('SQLite compression', () => {
       data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1),
       data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1),
       source_event_seqs: null,
       source_event_seqs: null,
       surface_op: null,
       surface_op: null,
-      is_packed: 1,
+      ignorable: 0,
     }
     }
     expect(() => decodeRow(oversized)).toThrow(/data exceeds/)
     expect(() => decodeRow(oversized)).toThrow(/data exceeds/)
   })
   })
@@ -359,7 +360,7 @@ describe('SQLite compression', () => {
       data: zstdCompressSync(serialized),
       data: zstdCompressSync(serialized),
       source_event_seqs: null,
       source_event_seqs: null,
       surface_op: null,
       surface_op: null,
-      is_packed: 1,
+      ignorable: 0,
     }
     }
     expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/)
     expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/)
   })
   })
@@ -401,7 +402,7 @@ describe('SQLite compression', () => {
       data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }),
       data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }),
       source_event_seqs: null,
       source_event_seqs: null,
       surface_op: null,
       surface_op: null,
-      is_packed: 1,
+      ignorable: 0,
     }
     }
     expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 })
     expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 })
   })
   })

+ 41 - 5
packages/session/session-persistence-sqlite/tests/differential.spec.ts

@@ -49,13 +49,14 @@ async function mount(name: BackendName, root: string): Promise<MountedBackend> {
 }
 }
 
 
 function closedChunkLog(
 function closedChunkLog(
-  entries: readonly { readonly chunk: StreamChunk; readonly time: number }[],
+  entries: readonly { readonly chunk: StreamChunk; readonly time: number; readonly ignorable?: true }[],
 ): SessionEvent[] {
 ): SessionEvent[] {
-  const chunks = entries.map(({ chunk, time }, index): SessionEvent => ({
+  const chunks = entries.map(({ chunk, time, ignorable }, index): SessionEvent => ({
     type: 'assistant/chunk',
     type: 'assistant/chunk',
     seq: index + 2,
     seq: index + 2,
     time,
     time,
     data: { turn: 1, step: 1, chunk },
     data: { turn: 1, step: 1, chunk },
+    ...ignorable === true ? { ignorable } : {},
   }))
   }))
   return [
   return [
     { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
     { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
@@ -72,7 +73,7 @@ function closedChunkLog(
 }
 }
 
 
 function packingMatrixLog(): SessionEvent[] {
 function packingMatrixLog(): SessionEvent[] {
-  const entries: { chunk: StreamChunk; time: number }[] = [
+  const entries: { chunk: StreamChunk; time: number; ignorable?: true }[] = [
     ...Array.from({ length: 5 }, (_, index) => ({
     ...Array.from({ length: 5 }, (_, index) => ({
       chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` },
       chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` },
       time: 1_000 + index,
       time: 1_000 + index,
@@ -103,12 +104,26 @@ function packingMatrixLog(): SessionEvent[] {
     { chunk: { type: 'block-start', index: 4, blockType: 'text' }, time: 4_000 },
     { chunk: { type: 'block-start', index: 4, blockType: 'text' }, time: 4_000 },
     { chunk: { type: 'text-delta', index: 4, text: 'short-a' }, time: 4_001 },
     { chunk: { type: 'text-delta', index: 4, text: 'short-a' }, time: 4_001 },
     { chunk: { type: 'text-delta', index: 4, text: 'short-b' }, time: 4_002 },
     { chunk: { type: 'text-delta', index: 4, text: 'short-b' }, time: 4_002 },
-    { chunk: { type: 'text-delta', index: 5, text: 'scalar-singleton' }, time: 4_003 },
+    { chunk: { type: 'text-delta', index: 5, text: 'scalar-envelope' }, time: 4_003, ignorable: true },
     { chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 },
     { chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 },
   ]
   ]
   return closedChunkLog(entries)
   return closedChunkLog(entries)
 }
 }
 
 
+function storageTagCollisionLog(): SessionEvent[] {
+  return [
+    { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+    ...['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].map((type, index) => ({
+      type,
+      seq: index + 1,
+      time: index + 2,
+      data: { future: true },
+      ignorable: true as const,
+    }) as unknown as SessionEvent),
+    { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
+  ]
+}
+
 function batches(events: readonly SessionEvent[], sizes: readonly number[]): SessionEvent[][] {
 function batches(events: readonly SessionEvent[], sizes: readonly number[]): SessionEvent[][] {
   const result: SessionEvent[][] = []
   const result: SessionEvent[][] = []
   let offset = 0
   let offset = 0
@@ -186,16 +201,35 @@ const randomWorkload = fc.record({
       { weight: 4, arbitrary: fc.integer({ min: 0, max: 10_000 }) },
       { weight: 4, arbitrary: fc.integer({ min: 0, max: 10_000 }) },
       { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
       { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
     ),
     ),
+    ignorable: fc.option(fc.constant<true>(true), { nil: undefined }),
   }), { maxLength: 30 }),
   }), { maxLength: 30 }),
   batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }),
   batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }),
 }).map(({ entries, batchSizes }) => ({
 }).map(({ entries, batchSizes }) => ({
-  events: JSON.parse(JSON.stringify(closedChunkLog(entries))) as SessionEvent[],
+  events: JSON.parse(JSON.stringify(closedChunkLog(entries.map(({ chunk, time, ignorable }) => ({
+    chunk,
+    time,
+    ...ignorable === true ? { ignorable } : {},
+  }))))) as SessionEvent[],
   batchSizes,
   batchSizes,
 }))
 }))
 
 
 const randomizedDifferentialTimeoutMs = process.platform === 'win32' ? 120_000 : 60_000
 const randomizedDifferentialTimeoutMs = process.platform === 'win32' ? 120_000 : 60_000
 
 
 describe('SQLite cross-backend differential behavior', () => {
 describe('SQLite cross-backend differential behavior', () => {
+  it('preserves ignorable logical events whose names match physical storage tags', async () => {
+    const events = storageTagCollisionLog()
+    const directory = await freshDirectory('dsh-sqlite-storage-tag-collision-')
+    const root = join(directory, 'sqlite')
+    await verifyBackend('sqlite', root, events, [2, 1])
+    const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true })
+    try {
+      expect(db.prepare(testSql('count-physical-types')).all()).toEqual([])
+      expect(db.prepare(testSql('count-ignorable-events')).get()).toEqual({ count: 3 })
+    } finally {
+      db.close()
+    }
+  })
+
   it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => {
   it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => {
     const events = packingMatrixLog()
     const events = packingMatrixLog()
     for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) {
     for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) {
@@ -219,6 +253,8 @@ describe('SQLite cross-backend differential behavior', () => {
                 { type: 'tool-call-chunks', count: 1 },
                 { type: 'tool-call-chunks', count: 1 },
               ],
               ],
             ][partitionIndex])
             ][partitionIndex])
+            expect(db.prepare(testSql('count-ignorable-events')).get())
+              .toEqual({ count: 1 })
           } finally {
           } finally {
             db.close()
             db.close()
           }
           }

+ 3 - 0
packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql

@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS count
+FROM events
+WHERE ignorable = 1;

+ 1 - 1
packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql

@@ -1,3 +1,3 @@
 SELECT COUNT(*) AS count
 SELECT COUNT(*) AS count
 FROM events
 FROM events
-WHERE type = 'text-chunks' AND is_packed = 1;
+WHERE type = 'text-chunks' AND ignorable = 0;

+ 1 - 1
packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql

@@ -1,6 +1,6 @@
 SELECT type, COUNT(*) AS count
 SELECT type, COUNT(*) AS count
 FROM events
 FROM events
 WHERE type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
 WHERE type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
-  AND is_packed = 1
+  AND ignorable = 0
 GROUP BY type
 GROUP BY type
 ORDER BY type;
 ORDER BY type;

+ 2 - 2
packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql

@@ -6,9 +6,9 @@ CREATE TABLE sessions (
 );
 );
 CREATE TABLE events (
 CREATE TABLE events (
   session_id ANY, seq ANY, type ANY, time ANY, data ANY,
   session_id ANY, seq ANY, type ANY, time ANY, data ANY,
-  source_event_seqs ANY, surface_op ANY, is_packed ANY
+  source_event_seqs ANY, surface_op ANY, ignorable ANY
 );
 );
 INSERT INTO persistence_state (singleton, store_id)
 INSERT INTO persistence_state (singleton, store_id)
 VALUES (1, '00000000-0000-4000-8000-000000000000');
 VALUES (1, '00000000-0000-4000-8000-000000000000');
 PRAGMA application_id = 1146308688;
 PRAGMA application_id = 1146308688;
-PRAGMA user_version = 19;
+PRAGMA user_version = 20;

+ 1 - 1
packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql

@@ -1,2 +1,2 @@
-INSERT INTO events (session_id, seq, type, time, data, is_packed)
+INSERT INTO events (session_id, seq, type, time, data, ignorable)
 VALUES ((SELECT id FROM sessions WHERE session_key = ?), ?, ?, ?, ?, ?);
 VALUES ((SELECT id FROM sessions WHERE session_key = ?), ?, ?, ?, ?, ?);

+ 1 - 1
packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql

@@ -8,7 +8,7 @@ CREATE TABLE events (
   data TEXT NOT NULL,
   data TEXT NOT NULL,
   source_event_seqs TEXT,
   source_event_seqs TEXT,
   surface_op TEXT,
   surface_op TEXT,
-  is_packed INTEGER,
+  ignorable INTEGER,
   PRIMARY KEY (session_id, seq)
   PRIMARY KEY (session_id, seq)
 );
 );
 DROP TABLE strict_events;
 DROP TABLE strict_events;

+ 1 - 1
packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql

@@ -1,4 +1,4 @@
-SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, is_packed
+SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, ignorable
 FROM events
 FROM events
 WHERE session_id = (SELECT id FROM sessions WHERE session_key = ?)
 WHERE session_id = (SELECT id FROM sessions WHERE session_key = ?)
 ORDER BY seq;
 ORDER BY seq;

+ 1 - 0
packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-20.sql

@@ -0,0 +1 @@
+PRAGMA user_version = 20;

+ 16 - 16
packages/session/session-persistence-sqlite/tests/sqlite.spec.ts

@@ -135,7 +135,7 @@ async function measureWriteTraffic(
     readonly data: string | Uint8Array
     readonly data: string | Uint8Array
     readonly source_event_seqs: Uint8Array | null
     readonly source_event_seqs: Uint8Array | null
     readonly surface_op: string | null
     readonly surface_op: string | null
-    readonly is_packed: number
+    readonly ignorable: number | null
   }
   }
   const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => (
   const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => (
     typeof left === 'string' || left === null
     typeof left === 'string' || left === null
@@ -150,7 +150,7 @@ async function measureWriteTraffic(
       && sameValue(left.data, right.data)
       && sameValue(left.data, right.data)
       && sameValue(left.source_event_seqs, right.source_event_seqs)
       && sameValue(left.source_event_seqs, right.source_event_seqs)
       && left.surface_op === right.surface_op
       && left.surface_op === right.surface_op
-      && left.is_packed === right.is_packed
+      && left.ignorable === right.ignorable
   )
   )
   const ctx = new Context()
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionStore)
@@ -223,7 +223,7 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
         : 1
         : 1
       const next = last.seq + logicalLength
       const next = last.seq + logicalLength
       db.prepare(testSql('insert-corrupt-event'))
       db.prepare(testSql('insert-corrupt-event'))
-        .run(id, next, 'assistant/chunk', 99, '{not valid json', 0)
+        .run(id, next, 'assistant/chunk', 99, '{not valid json', null)
       db.close()
       db.close()
     },
     },
     cleanup: async () => { await rm(directory, { recursive: true, force: true }) },
     cleanup: async () => { await rm(directory, { recursive: true, force: true }) },
@@ -327,7 +327,7 @@ describe('SessionPersistenceSqlite physical packing', () => {
 
 
     const db = new DatabaseSync(path)
     const db = new DatabaseSync(path)
     db.prepare(testSql('insert-corrupt-event'))
     db.prepare(testSql('insert-corrupt-event'))
-      .run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), 0)
+      .run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), null)
     db.close()
     db.close()
 
 
     expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)])
     expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)])
@@ -335,7 +335,7 @@ describe('SessionPersistenceSqlite physical packing', () => {
     const malformed = new DatabaseSync(path)
     const malformed = new DatabaseSync(path)
     malformed.prepare(testSql('delete-session-events')).run(header.id)
     malformed.prepare(testSql('delete-session-events')).run(header.id)
     malformed.prepare(testSql('insert-corrupt-event'))
     malformed.prepare(testSql('insert-corrupt-event'))
-      .run(header.id, 0, 'text-chunks', 1, '{not json', 1)
+      .run(header.id, 0, 'text-chunks', 1, '{not json', 0)
     malformed.close()
     malformed.close()
     expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([])
     expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([])
     await store.close()
     await store.close()
@@ -373,14 +373,14 @@ describe('SessionPersistenceSqlite physical packing', () => {
   it('rejects an older SQLite physical schema', async () => {
   it('rejects an older SQLite physical schema', async () => {
     const path = await freshDbPath('dsh-sqlite-old-schema-')
     const path = await freshDbPath('dsh-sqlite-old-schema-')
     const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
     const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
-    seed.exec(testSql('set-user-version-17'))
+    seed.exec(testSql('set-user-version-16'))
     seed.close()
     seed.close()
     await chmod(path, 0o600)
     await chmod(path, 0o600)
     await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS))
     await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS))
-      .rejects.toThrow(/schema version 17.*incompatible/)
+      .rejects.toThrow(/schema version 16.*incompatible/)
   })
   })
 
 
-  it('keeps the page size of an established schema 19 database', async () => {
+  it('keeps the page size of an established schema 20 database', async () => {
     const path = await freshDbPath('dsh-sqlite-page-size-')
     const path = await freshDbPath('dsh-sqlite-page-size-')
     const seed = await openDatabase(DatabaseSync, path, 'delete', DEFAULT_BUSY_TIMEOUT_MS)
     const seed = await openDatabase(DatabaseSync, path, 'delete', DEFAULT_BUSY_TIMEOUT_MS)
     seed.close()
     seed.close()
@@ -431,7 +431,7 @@ describe('SessionPersistenceSqlite physical packing', () => {
     const header = meta(SessionId('stale-repair'))
     const header = meta(SessionId('stale-repair'))
     await stale.appendBatch(header, [chunk(0)], false)
     await stale.appendBatch(header, [chunk(0)], false)
     const db = new DatabaseSync(path)
     const db = new DatabaseSync(path)
-    db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', 0)
+    db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
     db.close()
     db.close()
     expect((await stale.loadStored(header.id))?.tornMarker).toBe(1)
     expect((await stale.loadStored(header.id))?.tornMarker).toBe(1)
     await winner.commitRepair(header, 1, [])
     await winner.commitRepair(header, 1, [])
@@ -563,13 +563,13 @@ describe('SessionPersistenceSqlite schema ownership', () => {
 
 
     const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-')
     const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-')
     const incompatible = new DatabaseSync(incompatiblePath)
     const incompatible = new DatabaseSync(incompatiblePath)
-    incompatible.exec(testSql('set-user-version-17'))
+    incompatible.exec(testSql('set-user-version-16'))
     incompatible.close()
     incompatible.close()
     await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/)
     await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/)
 
 
     const foreignPath = await freshDbPath('dsh-sqlite-foreign-')
     const foreignPath = await freshDbPath('dsh-sqlite-foreign-')
     const foreign = new DatabaseSync(foreignPath)
     const foreign = new DatabaseSync(foreignPath)
-    foreign.exec(testSql('set-user-version-19'))
+    foreign.exec(testSql('set-user-version-20'))
     foreign.exec(testSql('set-application-id-12345'))
     foreign.exec(testSql('set-application-id-12345'))
     foreign.close()
     foreign.close()
     await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/)
     await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/)
@@ -599,7 +599,7 @@ describe('SessionPersistenceSqlite schema ownership', () => {
 
 
   it('rejects schema ownership changes observed at mutation time', async () => {
   it('rejects schema ownership changes observed at mutation time', async () => {
     const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
     const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
-    changedVersion.exec(testSql('set-user-version-17'))
+    changedVersion.exec(testSql('set-user-version-16'))
     expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') })
     expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') })
       .toThrow(/schema changed before mutation/)
       .toThrow(/schema changed before mutation/)
     changedVersion.close()
     changedVersion.close()
@@ -668,7 +668,7 @@ describe('SessionPersistenceSqlite schema ownership', () => {
 
 
     const eventRow = {
     const eventRow = {
       seq: 0, type: 'turn/start', time: 1, data: '{}',
       seq: 0, type: 'turn/start', time: 1, data: '{}',
-      source_event_seqs: null, surface_op: null, is_packed: 0,
+      source_event_seqs: null, surface_op: null, ignorable: null,
     }
     }
     for (const [value, message] of [
     for (const [value, message] of [
       [null, /object/],
       [null, /object/],
@@ -677,7 +677,7 @@ describe('SessionPersistenceSqlite schema ownership', () => {
       [{ ...eventRow, time: '1' }, /time.*safe integer/],
       [{ ...eventRow, time: '1' }, /time.*safe integer/],
       [{ ...eventRow, data: 1 }, /data.*string or blob/],
       [{ ...eventRow, data: 1 }, /data.*string or blob/],
       [{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/],
       [{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/],
-      [{ ...eventRow, is_packed: 2 }, /is_packed.*0 or 1/],
+      [{ ...eventRow, ignorable: 2 }, /ignorable.*0, 1, or null/],
     ] as const) {
     ] as const) {
       expect(() => decodeEventRow(value)).toThrow(message)
       expect(() => decodeEventRow(value)).toThrow(message)
     }
     }
@@ -787,7 +787,7 @@ describe('SessionPersistenceSqlite edge behavior', () => {
     const header = meta('repair-validation')
     const header = meta('repair-validation')
     await store.appendBatch(header, [chunk(0)], false)
     await store.appendBatch(header, [chunk(0)], false)
     const db = new DatabaseSync(path)
     const db = new DatabaseSync(path)
-    db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', 0)
+    db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
     db.close()
     db.close()
     await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/)
     await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/)
     await store.commitRepair(header, 1, [])
     await store.commitRepair(header, 1, [])
@@ -808,7 +808,7 @@ describe('SessionPersistenceSqlite edge behavior', () => {
     await store.appendBatch(header, [chunk(0)], false)
     await store.appendBatch(header, [chunk(0)], false)
     const db = new DatabaseSync(path)
     const db = new DatabaseSync(path)
     db.prepare(testSql('insert-corrupt-event'))
     db.prepare(testSql('insert-corrupt-event'))
-      .run(header.id, 1, 'assistant/chunk', 2, '{not json', 0)
+      .run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
     db.close()
     db.close()
 
 
     await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/)
     await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/)

+ 2 - 0
packages/session/session-persistence-sqlite/tests/test-sql.ts

@@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'
 export type TestSqlName =
 export type TestSqlName =
   | 'add-unexpected-column'
   | 'add-unexpected-column'
   | 'count-events'
   | 'count-events'
+  | 'count-ignorable-events'
   | 'count-packed-events'
   | 'count-packed-events'
   | 'count-physical-types'
   | 'count-physical-types'
   | 'create-loose-schema'
   | 'create-loose-schema'
@@ -27,6 +28,7 @@ export type TestSqlName =
   | 'set-user-version-17'
   | 'set-user-version-17'
   | 'set-user-version-18'
   | 'set-user-version-18'
   | 'set-user-version-19'
   | 'set-user-version-19'
+  | 'set-user-version-20'
   | 'update-invalid-session-metadata'
   | 'update-invalid-session-metadata'
   | 'vacuum'
   | 'vacuum'
 
 

+ 2 - 2
packages/session/session-persistence/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
 #   pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
-README.md: fc47f0c6a6b5fa7ad9eb0f12db1c1baf6684de78
-README.zh.md: 6e6982e4f2cb6637d2f657dba88e40a4b7a21f27
+README.md: 00ab8ffc76bac2a87a6ecf9fb3146cc24c7a976d
+README.zh.md: 5acc01272685c23039c1f6040c42fcb46c9912b0

+ 1 - 1
packages/session/session-persistence/README.md

@@ -51,7 +51,7 @@ Resume is `load` plus session preparation: the stored log comes back with its he
 
 
 ### Failures and recovery
 ### Failures and recovery
 
 
-A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SESSION_FORMAT_VERSION` remains v0 and this build provides no format-migration path; a newer version instructs the operator to upgrade the harness. The decoder accepts only the bounded same-version record variants named below. Every event type unknown to this build refuses reconstruction, while committed-prefix corruption rejects as `SessionPersistenceCorruptionError` ([rationale](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md)). A `load` on an id still bound to a live session first flushes its snapshot and rejects while its turn is open; a cold load applies recovery.
+A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SESSION_FORMAT_VERSION` remains v0 and this build provides no format-migration path; a newer version instructs the operator to upgrade the harness. The decoder accepts only the bounded same-version record variants named below. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`. A `load` on an id still bound to a live session first flushes its snapshot and rejects while its turn is open; a cold load applies recovery.
 
 
 -----
 -----
 
 

+ 1 - 1
packages/session/session-persistence/README.zh.md

@@ -51,7 +51,7 @@ const headers = await ctx.sessionPersistence.list()        // every stored sessi
 
 
 ### 失败与恢复
 ### 失败与恢复
 
 
-当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SESSION_FORMAT_VERSION` 保持 v0,本构建不提供格式迁移路径;更高版本会要求操作者升级 harness。解码器只接受下文点名的有限同版本记录变体。本构建不认识的每个事件类型都会拒绝重建,而已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝([理由](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md))。对仍绑定到活动会话的 id 执行 `load`,会先刷新其快照并在轮次开放时拒绝;冷 load 应用恢复。
+当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SESSION_FORMAT_VERSION` 保持 v0,本构建不提供格式迁移路径;更高版本会要求操作者升级 harness。解码器只接受下文点名的有限同版本记录变体。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。对仍绑定到活动会话的 id 执行 `load`,会先刷新其快照并在轮次开放时拒绝;冷 load 应用恢复。
 
 
 -----
 -----
 
 

+ 16 - 12
packages/session/session-persistence/src/coordinator.ts

@@ -48,9 +48,10 @@ export class SessionPersistenceCorruptionError extends Error {
 /**
 /**
  * The stored log is intact but this runtime cannot faithfully interpret it:
  * The stored log is intact but this runtime cannot faithfully interpret it:
  * the header carries an unsupported format version, or an event's type is
  * the header carries an unsupported format version, or an event's type is
- * unknown to this build. Distinct from {@link SessionPersistenceCorruptionError}
- * — nothing is damaged; the raw log remains readable at {@link location} when
- * the backend keeps one artifact per session.
+ * unknown to this build and the event is not marked ignorable. Distinct from
+ * {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log
+ * remains readable at {@link location} when the backend keeps one artifact
+ * per session.
  */
  */
 export class SessionFormatUnsupportedError extends Error {
 export class SessionFormatUnsupportedError extends Error {
   /**
   /**
@@ -709,8 +710,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
     // retired shape this backend refuses to load. The unknown-type guard is
     // retired shape this backend refuses to load. The unknown-type guard is
     // deliberately read-side only: an append-time refusal would stall a live
     // deliberately read-side only: an append-time refusal would stall a live
     // session's durability mid-flight, which costs more than a loud refusal at
     // session's durability mid-flight, which costs more than a loud refusal at
-    // the log's next load (trade-off owned by the fail-closed-session-event-
-    // vocabulary Agent Note).
+    // the log's next load (trade-off owned by the session-log-version-mechanism
+    // Agent Note).
     assertSupportedEvents(events, id)
     assertSupportedEvents(events, id)
     if (events.length === 0) return
     if (events.length === 0) return
     this.preparations.assertWritable(id)
     this.preparations.assertWritable(id)
@@ -1130,16 +1131,19 @@ export class PersistenceCoordinator<TornMarker = unknown> {
   }
   }
 
 
   /**
   /**
-   * Refuse a log containing an event type this build does not know: silently
-   * skipping an unknown event could reconstruct a wrong session. Runs on
-   * NORMALIZED events — after `snapshotStoredEvents`/`adoptStoredEvents` has
-   * upgraded the legacy shapes this build still reads and rejected the ones it
-   * does not, so those keep their specific diagnostics.
+   * Refuse a log containing an event type this build does not know, unless the
+   * writer marked the event ignorable: an unrecognized required event may
+   * change how the rest of the log must be interpreted, so silently skipping
+   * it would reconstruct a wrong session (the envelope contract on
+   * `SessionEvent.ignorable`). Runs on NORMALIZED events — after
+   * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes
+   * this build still reads and rejected the ones it does not, so those keep
+   * their specific diagnostics.
    */
    */
   private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
   private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
     for (const event of events) {
     for (const event of events) {
-      if (KNOWN_SESSION_EVENT_TYPES.has(event.type)) continue
-      throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness; refusing to interpret the log — it was likely written by a newer harness`)
+      if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
+      throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
     }
     }
   }
   }
 
 

+ 24 - 14
packages/session/session-persistence/tests/coordinator-contract.ts

@@ -706,21 +706,22 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
             .rejects.toThrow('lacks an identified message')
             .rejects.toThrow('lacks an identified message')
         }
         }
 
 
-        // A known log-only event with non-object data is not a legacy message
-        // candidate; both whole-log and seek reads preserve it unchanged.
-        const primitiveId = SessionId('non-object-log-only-event')
-        const primitive = {
-          type: 'session/end-seed',
+        // An out-of-repo event type passes only with the envelope's ignorable
+        // marker (unknown-type refusal otherwise), and its non-object data is
+        // not message-validated.
+        const pluginId = SessionId('non-object-plugin-event')
+        await ctx.sessionPersistence.create(meta(pluginId, WORK))
+        await ctx.sessionPersistence.append(pluginId, [{
+          type: 'plugin/test',
           seq: 0,
           seq: 0,
           time: 1,
           time: 1,
           data: null,
           data: null,
-        } as unknown as SessionEvent
-        await ctx.sessionPersistence.create(meta(primitiveId, WORK))
-        await ctx.sessionPersistence.append(primitiveId, [primitive])
-        await expect(ctx.sessionPersistence.inspect(primitiveId))
-          .resolves.toMatchObject({ events: [primitive] })
-        await expect(ctx.sessionPersistence.readFrom(primitiveId, 0))
-          .resolves.toMatchObject({ events: [primitive] })
+          ignorable: true,
+        } as unknown as SessionEvent])
+        await expect(ctx.sessionPersistence.inspect(pluginId))
+          .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] })
+        await expect(ctx.sessionPersistence.readFrom(pluginId, 0))
+          .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] })
 
 
         for (const type of ['user/message', 'assistant/message'] as const) {
         for (const type of ['user/message', 'assistant/message'] as const) {
           const missingContentId = SessionId(`invalid-${type}-without-content`)
           const missingContentId = SessionId(`invalid-${type}-without-content`)
@@ -1356,7 +1357,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       }
       }
     })
     })
 
 
-    it('rejects an unknown event type on load', async () => {
+    it('rejects an unknown event type on load unless the event is marked ignorable', async () => {
       const fix = await makeFixture()
       const fix = await makeFixture()
       const { ctx, fiber } = await freshCtx(fix)
       const { ctx, fiber } = await freshCtx(fix)
       try {
       try {
@@ -1368,7 +1369,16 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
         ])
         ])
         const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error)
         const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error)
         expect(failure?.name).toBe('SessionFormatUnsupportedError')
         expect(failure?.name).toBe('SessionFormatUnsupportedError')
-        expect(failure?.message).toMatch(/event type "future\/event".*unknown to this harness/)
+        expect(failure?.message).toMatch(/event type "future\/event".*not marked ignorable/)
+
+        const skippable = meta('unknown-ignorable', WORK)
+        await ctx.sessionPersistence.create(skippable)
+        await ctx.sessionPersistence.append(skippable.id, [
+          ...oneTurnLog(),
+          { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 }, ignorable: true } as unknown as SessionEvent,
+        ])
+        const loaded = await ctx.sessionPersistence.load(skippable.id)
+        expect(loaded.events.some(event => (event.type as string) === 'future/event')).toBe(true)
       } finally {
       } finally {
         await fiber.dispose()
         await fiber.dispose()
         await fix.cleanup()
         await fix.cleanup()

Некоторые файлы не были показаны из-за большого количества измененных файлов