Merge pull request #3087 from deepseek-harness/worktree/remove-ignorable-session-events

refactor(session): require known event types on read
This commit is contained in:
Tianyi Cui
2026-08-26 02:57:19 +08:00
committed by GitHub
74 changed files with 275 additions and 382 deletions
@@ -1,30 +0,0 @@
# 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, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating 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** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists.
@@ -1,30 +0,0 @@
# 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` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验本格式版本的 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。
## 曾考虑的替代方案
- **大小两级版本号**:能否转换这一位信息属于每一步的升级器,把它预先固化进编号形状会做出错误承诺。
- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。
- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。
- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
2026-08-18-sqlite-physical-chunk-row-compression.md: e46adf26ab4ce0a495f3509977ab0835631c16a9
2026-08-18-sqlite-physical-chunk-row-compression.zh.md: d93aa64a53effa9d456b3eba2e1681b9478c448d
2026-08-18-sqlite-physical-chunk-row-compression.md: 34aac2f183d386ffe22f86a6b62fe5e3105b3dfa
2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 1845185d543f565b55ace6adac973dad5535ad7b
@@ -12,15 +12,15 @@ A physical row that represents several events affects append contiguity, crash r
## Decision
`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-17 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-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.
Schema 17 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.
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.
SQLite owns chunk encoding and validation inside the schema-17 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-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.
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 17 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 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.
### 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.
`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-17 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-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.
### Schema ownership
A pristine database initializes at schema 17. 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 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.
### 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.
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.
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.
**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 17 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 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.
**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.
@@ -12,15 +12,15 @@ Status: implemented
## 决策
`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 17 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 18 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
Schema 17 保留普通 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` 成员。
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` 成员。
SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
SQLite 在 schema 18 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
`data` 列接受 `TEXT``BLOB`。序列化值小于 4 KiB 时保持为文本。达到或超过该阈值时,写入方使用 Zstandard level 3,并且只在 frame 小于原文本时保留该 frame;读取方会先解压,再进行严格 UTF-8 解码和 JSON 解析。固定的适中级别与阈值限制 frame 开销与同步 CPU 工作,同时覆盖占据大部分保留字节的重复 payload。
`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 17 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 18 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
### 事务化追加打包
@@ -32,11 +32,11 @@ SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的
完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。
`readFrom(id, fromSeq)` 只检查 schema 17 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
`readFrom(id, fromSeq)` 只检查 schema 18 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
### Schema 所有权
全新数据库初始化为 schema 17。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
全新数据库初始化为 schema 18。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
### 物理写入回归
@@ -58,11 +58,11 @@ SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的
**压缩每个 payload。** 不予采用,因为小型独立 Zstandard frame 会增加 header 和同步 CPU 工作,也无法利用整文件流的跨记录字典。在 105 个会话的对比语料上,阈值扫描结果为:4 KiB 生成 75.01 MB16 KiB 为 93.87 MB1 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 阈值是接受的平衡点,而不是严格支配所有指标的结论。
最终冻结对比包含 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 17schema 18 保留分片 codec 与上限,但改变行判别值,因此在重新测量 schema 18 前,精确的大小与时延值仍是 schema 17 证据。
**把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。
**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 17 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 18 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
**通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。
@@ -1,6 +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-10-session-log-version-mechanism.md
2026-08-10-session-log-version-mechanism.md: 81108ceaf23405c8f2def9aaef88505d635808a3
2026-08-10-session-log-version-mechanism.zh.md: cbb127420e2695853fdc2ad0bb98a7a0bf230b5b
# 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
@@ -0,0 +1,41 @@
# 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,0 +1,41 @@
# 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
View File
@@ -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.
- **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)).
- **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. A `SessionEventMap` member is required-on-read by default — builds that do not know its 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)).
- **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)).
- **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)).
- **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.
@@ -97,7 +97,7 @@ describe('session format guard through the assembled app', () => {
},
})
expect(result.stderr).toContain(
`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`,
`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`,
)
// macOS reports the temp dir via the /private symlink parent; assert the
// stable path suffix instead of the realpath-dependent prefix.
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/deepseek-llm-api-wire-extensions.md
deepseek-llm-api-wire-extensions.md: fd42609693ac6fbf91dd82b6e73b2d1d06e65a54
deepseek-llm-api-wire-extensions.zh.md: 61af718841c8778e8943a1a1c16621a9a6618add
deepseek-llm-api-wire-extensions.md: e44246d81494d3e18c7b9eca492635442f9ee82f
deepseek-llm-api-wire-extensions.zh.md: 1033ca6608479b6333d316aae830445c962fbbac
+2 -2
View File
@@ -128,7 +128,7 @@ The `session` member is the exact `Session.header`, not a complete runtime Sessi
### 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`; 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.
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.
### 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.
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.
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.
+2 -2
View File
@@ -128,7 +128,7 @@
### 权威事件信封
每个 `events` 元素都是完整的权威 `SessionEvent`,不依赖任何其他请求字段。事件始终携带 `type``seq``time``data`它可以携带 `ignorable: true`展示事件还可携带 `sourceEventSeqs``surfaceOp`。发送方会复制每个已有成员,不执行投影、脱敏或重建。
每个 `events` 元素都是完整的权威 `SessionEvent`,不依赖任何其他请求字段。事件始终携带 `type``seq``time``data`;展示事件还可携带 `sourceEventSeqs``surfaceOp`。发送方会复制每个已有成员,不执行投影、脱敏或重建。
### 接受水位与至少一次交付
@@ -156,4 +156,4 @@
请求标头会暴露 Harness 应用版本、一个匿名 Harness-home 身份和可选的会话身份。`dsh_plugin_packages` 会暴露存活 npm 包的名称与版本。启用后,`dsh_session_log` 可能暴露会话工作目录、系统提示词快照、用户与 assistant 内容、原始 assistant 分片、工具参数与结果、压缩摘要、反馈和插件持有的事件。适配器 API key 不是会话事件,因此不会进入该字段。通过 `baseURL` 选择的网关会收到与官方端点相同的值。
接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。遇到不带 `ignorable: true` 的未知权威事件时,接收方无法进行无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。
接收方按名称定位扩展字段,按各字段自己的 `version` 分派,保留不同的包版本,并忽略 JSON 成员顺序。会话日志接收方必须先校验连续序号范围,再解释事件类型。每个未知权威事件都会阻止无损重建。即使缺少注册表或某项贡献,基础请求仍然可用;字段缺失表示该项贡献不适用于本次请求。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: c4610aa3e6ad59764e4d3f85db33c8e0f285ac6e
event-producer-consumer.zh.md: d6f4903d93d3829ccf753ad98d9237094d726c3e
event-producer-consumer.md: 6353c42953fcdd460749bd19197b3bab0b936b66
event-producer-consumer.zh.md: 9879e6a3141ed078976beede6f1192867ee3d26b
+5 -5
View File
@@ -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/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) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:483`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../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` |
| `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` |
+5 -5
View File
@@ -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/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) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:483`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:510`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:489`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:496`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:502`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:482`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:488`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:495`](../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` |
| `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` |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: 7c63cd8dfdb181fac09cf3ce2e4bcec96d2bd59c
persistence-catalog.zh.md: f855d6969aa2dcade159ac8d6549e5f0350a7f0f
persistence-catalog.md: 1ff60ebdacf198ce7bc7d7c8c13eb9278320281a
persistence-catalog.zh.md: 59acfa23c81687f23498d598cb0aead3e669ceb2
+1 -12
View File
@@ -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).
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.
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.
## Event envelope
@@ -63,17 +63,6 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** Unix epoch milliseconds. */
time: number
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 ? {
/**
* Seq numbers of earlier events that this event cites as sources
+1 -12
View File
@@ -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)。
以下信封声明组合了每个事件的 `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))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。
以下信封声明组合了每个事件的 `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))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。
## 事件信封
@@ -65,17 +65,6 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** Unix epoch milliseconds. */
time: number
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 ? {
/**
* Seq numbers of earlier events that this event cites as sources
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/persistence.md
persistence.md: 098f5798e5313ca97e90e67dce1d67177f003ca7
persistence.zh.md: d6b3baf7cdb7f1735008e0c1da9740e0b756baff
persistence.md: 402836cb727fb99d92cea5e2a0d242b010aad2c9
persistence.zh.md: 424fc928d5a8ef18b403ea31e5a5d26d3fd3fdc7
+2 -2
View File
@@ -91,7 +91,7 @@ interface SessionHeader {
## 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 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).
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).
## `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:
- **[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 17 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 18 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 -->
+2 -2
View File
@@ -91,7 +91,7 @@ interface SessionHeader {
## 格式拒绝:本构建无法可靠读取的日志
后端用 `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)。
后端用 `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)。
## `CreateSessionOptions`seed 与元数据
@@ -233,7 +233,7 @@ interface SessionPersistenceSnapshot {
两者都实现同一个抽象 `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-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 17 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 18 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session.md
session.md: 421957b49216696fee18afe5b4b8f5b08e9986c7
session.zh.md: 33ac166cfb6ff3dd6448b9d6e499c7972f858be6
session.md: ef24802d7ea567681f935fea86efaab2eb2b80c0
session.zh.md: bee235657d1c1d1a6009eb23d51b8c88482c0748
-11
View File
@@ -201,17 +201,6 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** Unix epoch milliseconds. */
time: number
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 ? {
/**
* Seq numbers of earlier events that this event cites as sources
-11
View File
@@ -201,17 +201,6 @@ type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** Unix epoch milliseconds. */
time: number
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 ? {
/**
* Seq numbers of earlier events that this event cites as sources
@@ -391,7 +391,6 @@ export interface SessionWireEvent {
readonly seq: number
readonly time: number
readonly data: JsonValue
readonly ignorable?: true
readonly sourceEventSeqs?: number[]
readonly surfaceOp?: SurfaceOp
}
@@ -160,11 +160,11 @@ describe('Session attachment authorization', () => {
const inserted = imageRef('inserted')
const streamed = imageRef('streamed')
const events = [
{ ...event('fixture/direct', 0, {
event('fixture/direct', 0, {
content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, {
type: 'tool-result', content: [{ type: 'image', attachment: nested }],
}],
}), ignorable: true as const },
}),
{ ...event('assistant/message', 1, {
turn: 1,
step: 1,
@@ -30,7 +30,6 @@ function event(type: string, seq: number, data: unknown = {}): SessionEvent {
seq,
time: seq + 1,
data,
...type.startsWith('fixture/') ? { ignorable: true } : {},
} as SessionEvent
}
@@ -85,7 +85,6 @@ const sessionWireEventSchema = z.object({
seq: safeIntegerSchema,
time: safeIntegerSchema,
data: z.json(),
ignorable: z.literal(true).optional(),
sourceEventSeqs: z.array(safeIntegerSchema).optional(),
surfaceOp: z.json().optional(),
}).strict()
@@ -267,7 +266,7 @@ function append<Type extends keyof SessionEventMap>(
events: SessionEvent[],
type: Type,
data: SessionEventMap[Type],
options: { readonly surfaceOp?: 'append'; readonly ignorable?: true } = {},
options: { readonly surfaceOp?: 'append' } = {},
): void {
const seq = events.length
events.push({ type, seq, time: TIME_ZERO + seq, data, ...options } as SessionEvent<Type>)
@@ -280,7 +279,6 @@ function appendSeparator(events: SessionEvent[], run: number, separator: number)
seq,
time: TIME_ZERO + seq,
data: { run, separator },
ignorable: true,
} as SessionEvent)
}
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/session/README.md
README.md: 88228b2fe5024fbe2b81c63a542c01d4763e6621
README.zh.md: 68a0fdf94c25e8d9e716872ccd431257ed412f08
README.md: b96423846de1f0c902ed656ff879f7d9eb0536ef
README.zh.md: 61f1ff77f9427e121b56ae0205b36e7512782030
+1 -1
View File
@@ -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.
- **`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 unknown event types refuse reconstruction unless marked `ignorable` in the envelope ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.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)).
- **`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.
+1 -1
View File
@@ -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。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝任何其他版本,不认识的事件类型也会拒绝重建,除非信封带 `ignorable` 标记[机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md))。
- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝任何其他版本,每个不认识的事件类型也会拒绝重建([机制](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md))。
- **`TurnEndReasonMap` 不含 ACPAgent Client Protocol)命名的 `refusal``max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。
- **fork 之外没有会话树**:基于分支会话的 pi 风格条目树被推迟,除非消费方需要超越基于边界的 forking 的能力。
+1 -3
View File
@@ -223,7 +223,6 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
case 'data':
case 'surfaceOp':
case 'sourceEventSeqs':
case 'ignorable':
break
default:
throw new Error(`seed event at index ${index} has an invalid event envelope`)
@@ -235,8 +234,7 @@ function assertSessionEventEnvelope(value: Record<string, unknown>, index: numbe
if (typeof type !== 'string'
|| typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0
|| typeof time !== 'number' || !Number.isSafeInteger(time)
|| event['data'] === undefined
|| (event['ignorable'] !== undefined && event['ignorable'] !== true)) {
|| event['data'] === undefined) {
throw new Error(`seed event at index ${index} has an invalid event envelope`)
}
switch (type) {
@@ -8,10 +8,9 @@
/**
* Every `SessionEventMap` member declared in this repository — the event
* vocabulary this build understands. The persistence read path refuses to
* 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.
* 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.
* Downstream (out-of-repo) plugin events are outside this list by
* construction; a registration surface for them is deferred until such a
* consumer exists.
+6 -17
View File
@@ -45,13 +45,13 @@ export function SessionId(id: string): SessionId {
* wrong read). Only structural changes reach that bar: the header shape, the
* {@link SessionEvent} envelope, core event semantics, or the surface
* mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants).
* 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
* 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
* (upgrade-step chain, in-memory view conversion, migrate-on-continue) is
* recorded in the session-log-version-mechanism Agent Note
* (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`).
* recorded in the fail-closed-session-event-vocabulary Agent Note
* (`.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md`).
*/
export const SESSION_FORMAT_VERSION = 0
@@ -401,17 +401,6 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
/** Unix epoch milliseconds. */
time: number
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 ? {
/**
* Seq numbers of earlier events that this event cites as sources
@@ -1073,20 +1073,12 @@ describe('Session', () => {
{ ...base, time: '1' },
{ ...base, time: 0.5 },
{ type: base.type, seq: base.seq, time: base.time },
{ ...base, ignorable: false },
{ ...base, ignorable: 'yes' },
]
for (const [index, event] of cases.entries()) {
expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent]))
.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)
})
})
@@ -72,7 +72,6 @@ interface EventDraft {
readonly data: unknown
readonly surfaceOp?: 'append'
readonly sourceEventSeqs?: number[]
readonly ignorable?: true
}
class EventLog {
@@ -4485,7 +4485,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
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 ignorable?: true;\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 } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];',
},
{
name: 'SessionEventEntry',
@@ -4845,7 +4845,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionWireEvent',
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}',
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}',
},
{
name: 'SettingsApplies',
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence-sqlite/README.md
README.md: fd67793546b8f0beaad38137328d3095cc336e12
README.zh.md: e2d79a7c27896bfd746bcf0b60a0447ebb5c20c0
README.md: 5f8d4cebf1a189cd768673359686ab2774550285
README.zh.md: 12900cc8783430b8716612cdda55d2622cc7f7aa
@@ -33,7 +33,7 @@ Choose this backend when a local deployment benefits from one queryable database
### Disk footprint and performance
The packed layout trades disk space for speed and structure. On the benchmark corpus behind schema 17 — 105 sessions, about 2.5 million events — the SQLite database used 75 MB against 31 MB for the default compressed JSONL logs: roughly 2.5× the on-disk size.
The packed layout trades disk space for speed and structure. The available benchmark measures schema 17, the packed predecessor with the same chunk codec but the former row discriminator; schema 18 has not been remeasured. On its corpus — 105 sessions, about 2.5 million events — the SQLite database used 75 MB against 31 MB for the default compressed JSONL logs: roughly 2.5× the on-disk size.
The same measurements show writes finishing about 3× faster, 50-event suffix reads about 40× faster, full-session reads comparable or slightly faster, and about 2.5 million physical rows shrinking to roughly 66 thousand. Expect 23× the compressed JSONL footprint depending on session content; the full numbers and method live in the [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md).
@@ -77,7 +77,7 @@ await ctx.sessionPersistence.append(id, events)
### Startup and safe operation
A fresh database initializes directly at schema version 17. 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 18. 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.
@@ -96,7 +96,7 @@ This section explains the design decisions behind the provider and points at the
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.
- **The schema owns the format.** Schema 17 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 physical rules requires a new schema.
- **The schema owns the format.** Schema 18 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 physical rules requires a new schema.
- **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.
@@ -124,7 +124,7 @@ A fresh database contains three strict tables, defined in [`resources/sql/schema
| `sessions` | One row per session: header fields plus a monotonic revision |
| `events` | Physical event rows: one logical event, or one packed run |
The exact columns live in [`resources/sql/schema.sql`](resources/sql/schema.sql). `events.data` holds text or a blob: small payloads stay text, larger ones are stored compressed when that is smaller. 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). `events.data` holds text or a blob: small payloads stay text, larger ones are stored compressed when that is smaller. `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.
### Write path
@@ -146,7 +146,7 @@ Read these pages when the package-level contract is not enough. They move from t
- [Session persistence subsystem](../../../docs/subsystems/persistence.md) — backend-neutral service semantics and provider relationships.
- [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.
- [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) — rationale, alternatives, and measurements behind schema 17.
- [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.
-----
@@ -174,7 +174,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.
- **Pre-release design with no migration** — schema 17 is an interim SQLite-only design; the deferred unified multi-backend relational design with configurable schemas exists as a working external prototype in [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb) (Drizzle-based, SQLite and PostgreSQL), and neither schema stability nor migration support is guaranteed.
- **Pre-release design with no migration** — schema 18 is an interim SQLite-only design; the deferred unified multi-backend relational design with configurable schemas exists as a working external prototype in [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb) (Drizzle-based, SQLite and PostgreSQL), and 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.
- **Synchronous SQLite and compression** — Node's SQLite driver and Zstandard calls block the JavaScript thread; the 4 KiB compression threshold bounds per-frame work for small records.
- **Busy waits block the event loop** — SQLite waits inside synchronous calls; a competing writer can stall the thread for up to the configured `busyTimeoutMs`.
@@ -191,7 +191,7 @@ This Dev Note is working context for maintainers: measured artifacts, open desig
#### Benchmark artifact
The numbers below are the benchmark behind schema 17; the [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) is the authoritative record, and this table is an annotated digest.
The numbers below are the frozen schema-17 benchmark. Schema 18 changes the row discriminator and has not been remeasured; the [SQLite physical chunk-row decision](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md) is the authoritative record, and this table is an annotated digest.
| Metric | **JSONL (zstd)** | **SQLite (legacy)** | **SQLite (new)** |
|---|---|---|---|
@@ -202,7 +202,7 @@ The numbers below are the benchmark behind schema 17; the [SQLite physical chunk
| Event rows | 2,507,860 (logical) | 2,507,860 | **65,810** |
| Fork of all sessions | 14.48 s | 19.30 s | **13.10 s** |
The corpus was 105 sessions with 2,507,860 logical events appended in 512-event durable batches, so the ratios depend on session content, stream density, and batch boundaries. `SQLite (legacy)` is the scalar layout — one physical row per logical event, no packing — whose 709.57 MB footprint motivated the packed rows. Against JSONL, schema 17 uses ≈2.5× the disk space but writes ≈3.3× faster, reads complete sessions faster at both percentiles, and reads 50-event tails ≈40× faster; against the scalar layout it is ≈89% smaller, faster to write, and shrinks 2,507,860 rows to 65,810, while scalar tail reads remain marginally faster (0.189 vs 0.253 ms p50). Re-run or extend this benchmark whenever the write path or the schema changes.
The corpus was 105 sessions with 2,507,860 logical events appended in 512-event durable batches, so the ratios depend on session content, stream density, and batch boundaries. `SQLite (legacy)` is the scalar layout — one physical row per logical event, no packing — whose 709.57 MB footprint motivated the packed rows. In the measured schema-17 layout, SQLite uses ≈2.5× the JSONL disk space but writes ≈3.3× faster, reads complete sessions faster at both percentiles, and reads 50-event tails ≈40× faster; against the scalar layout it is ≈89% smaller, faster to write, and shrinks 2,507,860 rows to 65,810, while scalar tail reads remain marginally faster (0.189 vs 0.253 ms p50). Re-run or extend this benchmark whenever the write path or the schema changes.
#### Future: multi-backend RDB persistence (Drizzle)
@@ -210,7 +210,7 @@ A unified multi-backend relational design stays deferred. A Drizzle-backed rewor
#### Future: persistence-to-persistence transfer and version migration
The README documents a manual `load``create`/`append` transfer, but the seam has no import/export API, and SQLite rejects other schema versions outright. Automating transfer needs: an export format that preserves header lineage (`seedLength`, `parentSession`, `agentPreset`) and revision semantics; an upgrader chain for format and schema versions, the deferred chain from the [session-log-version-mechanism note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md); and a source-side guarantee that the log is readable and balanced before export — `load` already commits cold repair.
The README documents a manual `load``create`/`append` transfer, but the seam has no import/export API, and SQLite rejects other schema versions outright. Automating transfer needs: an export format that preserves header lineage (`seedLength`, `parentSession`, `agentPreset`) and revision semantics; an upgrader chain for format and schema versions, the deferred chain from the [fail-closed event-vocabulary note](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.md); and a source-side guarantee that the log is readable and balanced before export — `load` already commits cold repair.
#### Future: in-database full-text search and indexing
@@ -33,7 +33,7 @@ kind: "package-reference"
### 磁盘占用与性能
打包布局以磁盘空间换取速度与结构。 schema 17 的基准语料上——105 个会话、约 250 万个事件——SQLite 数据库占用 75 MB,而默认压缩 JSONL 日志为 31 MB:磁盘占用约为后者的 2.5 倍。
打包布局以磁盘空间换取速度与结构。现有基准测量 schema 17,该打包前身使用相同的分片 codec,但行判别值不同;schema 18 尚未重新测量。在该语料上——105 个会话、约 250 万个事件——SQLite 数据库占用 75 MB,而默认压缩 JSONL 日志为 31 MB:磁盘占用约为后者的 2.5 倍。
同一组测量显示,写入快约 3 倍,50 个事件的后缀读取快约 40 倍,完整会话读取相当或略快,约 250 万个物理行缩减到约 6.6 万个。按会话内容不同,磁盘占用约为压缩 JSONL 的 2–3 倍;完整数据与方法见 [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)。
@@ -77,7 +77,7 @@ await ctx.sessionPersistence.append(id, events)
### 启动与安全运行
全新数据库直接初始化为 schema 版本 17。任何其他版本、外来应用标识、无版本的非全新 schema 或意外 schema 对象,都会在任何数据暴露或变更之前被拒绝——本预发布提供方不提供迁移。每条语句和固定 pragma 都来自 `resources/sql/` 下打包的 `.sql` 资源,运行时的值以 SQLite 参数绑定,包代码从不拼装查询文本。
全新数据库直接初始化为 schema 版本 18。任何其他版本、外来应用标识、无版本的非全新 schema 或意外 schema 对象,都会在任何数据暴露或变更之前被拒绝——本预发布提供方不提供迁移。每条语句和固定 pragma 都来自 `resources/sql/` 下打包的 `.sql` 资源,运行时的值以 SQLite 参数绑定,包代码从不拼装查询文本。
每个连接都会禁用 SQLite trusted schema 与内存映射 I/O、验证所请求的 journal mode,并固定 `synchronous=FULL`,保证成功返回的追加在操作系统崩溃或断电后依然持久。在 POSIX 上,数据库父目录和文件必须属于当前用户,父目录不得允许组或其他用户写入,文件也不得授予任何组或其他用户权限;Windows 还会拒绝符号链接和非普通文件,ACL 限制则由部署方负责。路径与所有权失败会拒绝插件初始化;Node 的 SQLite 驱动在首次持久化操作时才延迟加载。普通 `create` 会保持惰性直到首次 append,而 `ensureMaterialized` 会写入一条没有事件行的会话元数据记录。
@@ -96,7 +96,7 @@ await ctx.sessionPersistence.append(id, events)
本提供方建立在一个分离与三项承诺之上:
- **逻辑约定,物理格式。** 调用方始终读写普通的 `SessionEvent[]`;行如何打包、存储与压缩是本包私有的存储行为。
- **schema 拥有格式。** Schema 17 是冻结的物理约定:任何其他版本、外来标识或意外 schema 对象的数据库都会被拒绝,绝不迁移。改变物理规则需要新的 schema。
- **schema 拥有格式。** Schema 18 是冻结的物理约定:任何其他版本、外来标识或意外 schema 对象的数据库都会被拒绝,绝不迁移。改变物理规则需要新的 schema。
- **持久性是默认值。** 追加在立即事务中以 `synchronous=FULL` 提交,成功返回的 `append()` 意味着该批次已持久。普通追加仅插入:更早的事件行永远不会被重写。
- **在严格边界内追求效率。** 打包与压缩让数据库保持小巧,但每个上限都是硬性格式边界——每个打包行至多表示 1,024 个事件、1 MiB 载荷。
@@ -124,7 +124,7 @@ await ctx.sessionPersistence.append(id, events)
| `sessions` | 每个会话一行:头部字段加单调递增的 revision |
| `events` | 物理事件行:一个逻辑事件,或一个打包连续段 |
确切的列定义见 [`resources/sql/schema.sql`](resources/sql/schema.sql)。`events.data` 列存放文本或 blob:小载荷保持为文本,较大的载荷在压缩后更小时以压缩形式存储。打包行沿用其首个逻辑事件的 `seq`,因此在复合主键 `(session_id, seq)` 下,物理顺序就是逻辑顺序。
确切的列定义见 [`resources/sql/schema.sql`](resources/sql/schema.sql)。`events.data` 列存放文本或 blob:小载荷保持为文本,较大的载荷在压缩后更小时以压缩形式存储。标量逻辑事件的 `events.is_packed``0`,打包分片连续段的该值为 `1`,因此类型与物理分片标签同名的标量事件仍然明确。打包行沿用其首个逻辑事件的 `seq`,因此在复合主键 `(session_id, seq)` 下,物理顺序就是逻辑顺序。
### 写入路径
@@ -146,7 +146,7 @@ await ctx.sessionPersistence.append(id, events)
- [会话持久化子系统](../../../docs/subsystems/persistence.zh.md)——后端无关的服务语义与提供方关系。
- [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
- [生成配置目录](../../../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)——schema 17 背后的理由、备选方案与测量。
- [SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md)——打包布局背后的理由、备选方案与测量。
-----
@@ -174,7 +174,7 @@ await ctx.sessionPersistence.append(id, events)
这些限制说明本提供方何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是通用 SQLite 对比或任务积压。
- **预发布设计,无迁移**——schema 17 是临时的 SQLite 专用设计;被推迟的统一多后端、可配置 schema 关系型设计已有可运行的外部原型 [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb)(基于 Drizzle,支持 SQLite 与 PostgreSQL),预发布期间不保证 schema 稳定性或迁移支持。
- **预发布设计,无迁移**——schema 18 是临时的 SQLite 专用设计;被推迟的统一多后端、可配置 schema 关系型设计已有可运行的外部原型 [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb)(基于 Drizzle,支持 SQLite 与 PostgreSQL),预发布期间不保证 schema 稳定性或迁移支持。
- **打包依赖批次边界**——被写后窗口或显式 flush 拆开的兼容连续段仍分属不同物理行;这避免了重写先前行,代价是打包比例依赖时序。
- **同步 SQLite 与压缩**——Node 的 SQLite 驱动与 Zstandard 调用会阻塞 JavaScript 线程;4 KiB 压缩阈值限制了小记录的单帧工作量。
- **忙等待阻塞事件循环**——SQLite 在同步调用内部等待;竞争写入方最长可让线程停顿配置的 `busyTimeoutMs`
@@ -191,7 +191,7 @@ await ctx.sessionPersistence.append(id, events)
#### 基准产物
以下数字是 schema 17 背后的基准;[SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md) 是权威记录,本表只是带注的摘要。
以下数字是冻结的 schema 17 基准。Schema 18 改变了行判别值,尚未重新测量[SQLite 物理分片行决策](../../../.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md) 是权威记录,本表只是带注的摘要。
| 指标 | **JSONLzstd** | **SQLitelegacy** | **SQLitenew** |
|---|---|---|---|
@@ -202,7 +202,7 @@ await ctx.sessionPersistence.append(id, events)
| 事件行数 | 2,507,860(逻辑) | 2,507,860 | **65,810** |
| 全部会话 fork | 14.48 s | 19.30 s | **13.10 s** |
语料为 105 个会话、2,507,860 个逻辑事件,按 512 个事件一批追加,因此具体比例取决于会话内容、流密度与批次边界。`SQLitelegacy` 是标量布局——每个逻辑事件一行、不打包——其 709.57 MB 的占用正是打包行的动机。相对 JSONLschema 17 磁盘占用约为 2.5 倍,但写入快约 3.3 倍,完整会话读取在两个分位上都更快,50 个事件尾部读取快约 40 倍;相对标量布局,它缩小约 89%、写入更快,并把 2,507,860 行缩减到 65,810 行,只有标量尾部读取仍略快(0.189 对 0.253 ms p50)。写入路径或 schema 变化时,请重跑或扩展该基准。
语料为 105 个会话、2,507,860 个逻辑事件,按 512 个事件一批追加,因此具体比例取决于会话内容、流密度与批次边界。`SQLitelegacy` 是标量布局——每个逻辑事件一行、不打包——其 709.57 MB 的占用正是打包行的动机。在已测量的 schema 17 布局中,SQLite 磁盘占用约为 JSONL 的 2.5 倍,但写入快约 3.3 倍,完整会话读取在两个分位上都更快,50 个事件尾部读取快约 40 倍;相对标量布局,它缩小约 89%、写入更快,并把 2,507,860 行缩减到 65,810 行,只有标量尾部读取仍略快(0.189 对 0.253 ms p50)。写入路径或 schema 变化时,请重跑或扩展该基准。
#### 未来:多后端 RDB 持久化(Drizzle
@@ -210,7 +210,7 @@ await ctx.sessionPersistence.append(id, events)
#### 未来:持久化到持久化的迁移与版本升级
README 记录了手动的 `load``create`/`append` 迁移,但 seam 没有导入/导出 APISQLite 也直接拒绝其他 schema 版本。自动化迁移需要:能保留头部血缘(`seedLength``parentSession``agentPreset`)与 revision 语义的导出格式;格式与 schema 版本的升级链,即 [session-log-version-mechanism 笔记](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md) 中推迟的升级链;以及导出前源日志可读且平衡的保证——`load` 已先提交冷修复。
README 记录了手动的 `load``create`/`append` 迁移,但 seam 没有导入/导出 APISQLite 也直接拒绝其他 schema 版本。自动化迁移需要:能保留头部血缘(`seedLength``parentSession``agentPreset`)与 revision 语义的导出格式;格式与 schema 版本的升级链,即[事件词汇表显式拒绝笔记](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md)中推迟的升级链;以及导出前源日志可读且平衡的保证——`load` 已先提交冷修复。
#### 未来:库内全文搜索与索引改进
@@ -1,3 +1,3 @@
INSERT INTO events
(session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable)
(session_id, seq, type, time, data, source_event_seqs, surface_op, is_packed)
VALUES (?, ?, ?, ?, ?, ?, ?, ?);
@@ -25,6 +25,6 @@ CREATE TABLE events (
data ANY NOT NULL,
source_event_seqs ANY,
surface_op TEXT,
ignorable INTEGER CHECK (ignorable IS NULL OR ignorable IN (0, 1)),
is_packed INTEGER NOT NULL CHECK (is_packed IN (0, 1)),
PRIMARY KEY (session_id, seq)
) STRICT;
@@ -1,4 +1,4 @@
SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
SELECT seq, type, time, data, source_event_seqs, surface_op, is_packed
FROM events
WHERE session_id = ? AND seq >= ?
ORDER BY seq;
@@ -1,4 +1,4 @@
SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
SELECT seq, type, time, data, source_event_seqs, surface_op, is_packed
FROM events
WHERE session_id = ?
ORDER BY seq;
@@ -1,6 +1,6 @@
SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
SELECT seq, type, time, data, source_event_seqs, surface_op, is_packed
FROM events
WHERE session_id = ? AND seq >= ? AND seq < ?
AND type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
AND ignorable = 0
AND is_packed = 1
ORDER BY seq;
@@ -1,4 +1,4 @@
SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
SELECT seq, type, time, data, source_event_seqs, surface_op, is_packed
FROM events
WHERE session_id = ?
ORDER BY seq DESC
@@ -1 +0,0 @@
PRAGMA user_version = 17;
@@ -0,0 +1 @@
PRAGMA user_version = 18;
@@ -1,5 +1,5 @@
/**
* Schema-17 physical chunk-row codec. This package owns the durable tags,
* Schema-18 physical chunk-row codec. This package owns the durable tags,
* validation, and row-size limits independently from other persistence formats.
* @module @deepseek-ai/dsh-session-persistence-sqlite/codec
*/
@@ -7,7 +7,7 @@
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/* jscpd:ignore-start -- schema 17 deliberately owns a frozen physical codec;
/* jscpd:ignore-start -- schema 18 deliberately owns a frozen physical codec;
* importing or sharing the JSONL codec would let that format mutate this database interpreter. */
type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
type DeltaEvent = SessionEvent<'assistant/chunk'>
@@ -29,13 +29,13 @@ interface ToolCallRunData extends RunDataBase {
readonly args: string[]
}
/** One schema-17 packed physical record. */
/** One schema-18 packed physical record. */
export type ChunkRow =
| { 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: 'tool-call-chunks'; readonly seq0: number; readonly time0: number; readonly data: ToolCallRunData }
/** One scalar event or schema-17 packed physical record. */
/** One scalar event or schema-18 packed physical record. */
export type StorageRecord = SessionEvent | ChunkRow
/** 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-17 records.
* Pack eligible logical chunk runs into bounded schema-18 records.
* @param events - logical events in sequence 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-17 record.
* Decode one scalar or packed schema-18 record.
* @param value - parsed physical-record value.
* @returns the represented logical events.
*/
@@ -24,7 +24,7 @@ export interface BoundRecord {
readonly data: string | Uint8Array
readonly sourceEventSeqs: Uint8Array | null
readonly surfaceOp: string | null
readonly ignorable: number | null
readonly isPacked: 0 | 1
}
/** Small values stay as SQLite text to avoid per-frame CPU and byte overhead. */
@@ -34,8 +34,6 @@ const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER)
const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
const ZSTD_COMPRESSION_LEVEL = 3
const PACKED_ROW_SENTINEL = 0
const CHUNK_TAGS = ['text-chunks', 'reasoning-chunks', 'tool-call-chunks'] as const
type ChunkTag = typeof CHUNK_TAGS[number]
@@ -49,7 +47,7 @@ function isChunkTag(value: string): value is ChunkTag {
* @returns every logical event represented by the row.
*/
export function decodeRow(row: EventRow): SessionEvent[] {
if (row.ignorable !== PACKED_ROW_SENTINEL) return [decodeScalarRow(row)]
if (row.is_packed === 0) return [decodeScalarRow(row)]
if (!isChunkTag(row.type)) {
throw new Error(`malformed ${row.type} storage row: packed discriminator requires a chunk tag`)
}
@@ -78,7 +76,7 @@ export function bindRecord(record: StorageRecord): BoundRecord {
data: encodeData(JSON.stringify(record.data)),
sourceEventSeqs: null,
surfaceOp: null,
ignorable: PACKED_ROW_SENTINEL,
isPacked: 1,
}
}
const event = record
@@ -92,7 +90,7 @@ export function bindRecord(record: StorageRecord): BoundRecord {
? null
: encodeSourceEventSeqs(surface.sourceEventSeqs),
surfaceOp: surface.surfaceOp === undefined ? null : JSON.stringify(surface.surfaceOp),
ignorable: event.ignorable === true ? 1 : null,
isPacked: 0,
}
}
@@ -212,7 +210,6 @@ function decodeScalarRow(row: EventRow): SessionEvent {
time: row.time,
data: JSON.parse(decodeData(row.data)) as SessionEvent['data'],
...surfaceFields,
...row.ignorable === 1 ? { ignorable: true as const } : {},
} as SessionEvent
}
@@ -1,6 +1,6 @@
/**
* Opt-in SQLite persistence provider. Logical sessions remain unchanged;
* the physical backend packs eligible chunk runs into schema-17 rows.
* the physical backend packs eligible chunk runs into schema-18 rows.
* @module @deepseek-ai/dsh-session-persistence-sqlite
*/
@@ -15,7 +15,7 @@ import {
import { sql } from './sql.ts'
/** Current physical-record schema with packed and compressed event rows. */
export const SCHEMA_VERSION = 17
export const SCHEMA_VERSION = 18
/** Application id reserved for DeepSeek Harness SQLite session databases. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
@@ -42,7 +42,7 @@ export interface EventRow {
readonly data: string | Uint8Array
readonly source_event_seqs: Uint8Array | null
readonly surface_op: string | null
readonly ignorable: number | null
readonly is_packed: 0 | 1
}
/** Durable journal modes accepted by the backend. */
@@ -206,7 +206,7 @@ function initializeDatabase(db: DatabaseSync): void {
db.exec(sql('schema'))
db.prepare(sql('insert-persistence-state')).run(randomUUID())
db.exec(sql('set-application-id'))
db.exec(sql('set-user-version-17'))
db.exec(sql('set-user-version-18'))
}
let canonicalSchema: readonly SchemaObjectRow[] | undefined
@@ -313,9 +313,9 @@ export function decodeSessionRow(value: unknown): SessionRow {
*/
export function decodeEventRow(value: unknown): EventRow {
const row = record(value, 'stored event')
const ignorable = nullableSafeIntegerField(row, 'ignorable')
if (ignorable !== null && ignorable !== 0 && ignorable !== 1) {
throw new Error('stored event ignorable must be 0, 1, or null')
const isPacked = safeIntegerField(row, 'is_packed')
if (isPacked !== 0 && isPacked !== 1) {
throw new Error('stored event is_packed must be 0 or 1')
}
return {
seq: nonnegativeSafeIntegerField(row, 'seq'),
@@ -324,7 +324,7 @@ export function decodeEventRow(value: unknown): EventRow {
data: stringOrBlobField(row, 'data'),
source_event_seqs: nullableBlobField(row, 'source_event_seqs'),
surface_op: nullableStringField(row, 'surface_op'),
ignorable,
is_packed: isPacked,
}
}
@@ -36,7 +36,7 @@ const SQL_RESOURCES = [
'select-user-object-count',
'select-user-version',
'set-application-id',
'set-user-version-17',
'set-user-version-18',
'synchronous-full',
'trusted-schema-off',
'update-session-revision',
@@ -376,7 +376,7 @@ export class SqliteStore implements PersistenceBackend<number> {
record.data,
record.sourceEventSeqs,
record.surfaceOp,
record.ignorable,
record.isPacked,
)
}
@@ -43,7 +43,7 @@ function row(record: StorageRecord): EventRow {
data: bound.data,
source_event_seqs: bound.sourceEventSeqs,
surface_op: bound.surfaceOp,
ignorable: bound.ignorable,
is_packed: bound.isPacked,
}
}
@@ -160,7 +160,7 @@ describe('SQLite compression', () => {
expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/)
})
it('decodes the schema-17 row vocabulary without another package codec', () => {
it('decodes the schema-18 row vocabulary without another package codec', () => {
const fixture: EventRow = {
seq: 7,
type: 'text-chunks',
@@ -168,7 +168,7 @@ describe('SQLite compression', () => {
data: JSON.stringify({ turn: 2, step: 3, index: 1, dt: [2, -1], texts: ['a', 'b', 'c'] }),
source_event_seqs: null,
surface_op: null,
ignorable: 0,
is_packed: 1,
}
expect(decodeRow(fixture)).toEqual([
{ ...chunk(7, 'a'), time: 90, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'a' } } },
@@ -192,22 +192,21 @@ describe('SQLite compression', () => {
it('rejects the packed discriminator on a scalar event type', () => {
const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
expect(() => decodeRow({ ...scalar, ignorable: 0 }))
expect(() => decodeRow({ ...scalar, is_packed: 1 }))
.toThrow(/packed discriminator requires a chunk tag/)
})
it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])(
'preserves an ignorable logical event named %s as a scalar row',
'preserves a logical event named %s as a scalar row',
(type) => {
const logical = {
type,
seq: 0,
time: 1,
data: { future: true },
ignorable: true,
} as unknown as SessionEvent
const physical = row(logical)
expect(physical.ignorable).toBe(1)
expect(physical.is_packed).toBe(0)
expect(decodeRow(physical)).toEqual([logical])
},
)
@@ -288,7 +287,7 @@ describe('SQLite compression', () => {
data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1),
source_event_seqs: null,
surface_op: null,
ignorable: 0,
is_packed: 1,
}
expect(() => decodeRow(oversized)).toThrow(/data exceeds/)
})
@@ -308,7 +307,7 @@ describe('SQLite compression', () => {
data: zstdCompressSync(serialized),
source_event_seqs: null,
surface_op: null,
ignorable: 0,
is_packed: 1,
}
expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/)
})
@@ -350,7 +349,7 @@ describe('SQLite compression', () => {
data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }),
source_event_seqs: null,
surface_op: null,
ignorable: 0,
is_packed: 1,
}
expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 })
})
@@ -49,14 +49,13 @@ async function mount(name: BackendName, root: string): Promise<MountedBackend> {
}
function closedChunkLog(
entries: readonly { readonly chunk: StreamChunk; readonly time: number; readonly ignorable?: true }[],
entries: readonly { readonly chunk: StreamChunk; readonly time: number }[],
): SessionEvent[] {
const chunks = entries.map(({ chunk, time, ignorable }, index): SessionEvent => ({
const chunks = entries.map(({ chunk, time }, index): SessionEvent => ({
type: 'assistant/chunk',
seq: index + 2,
time,
data: { turn: 1, step: 1, chunk },
...ignorable === true ? { ignorable } : {},
}))
return [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
@@ -73,7 +72,7 @@ function closedChunkLog(
}
function packingMatrixLog(): SessionEvent[] {
const entries: { chunk: StreamChunk; time: number; ignorable?: true }[] = [
const entries: { chunk: StreamChunk; time: number }[] = [
...Array.from({ length: 5 }, (_, index) => ({
chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` },
time: 1_000 + index,
@@ -104,26 +103,12 @@ function packingMatrixLog(): SessionEvent[] {
{ 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-b' }, time: 4_002 },
{ chunk: { type: 'text-delta', index: 5, text: 'scalar-envelope' }, time: 4_003, ignorable: true },
{ chunk: { type: 'text-delta', index: 5, text: 'scalar-singleton' }, time: 4_003 },
{ chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 },
]
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[][] {
const result: SessionEvent[][] = []
let offset = 0
@@ -201,33 +186,14 @@ const randomWorkload = fc.record({
{ 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 }) },
),
ignorable: fc.option(fc.constant<true>(true), { nil: undefined }),
}), { maxLength: 30 }),
batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }),
}).map(({ entries, batchSizes }) => ({
events: JSON.parse(JSON.stringify(closedChunkLog(entries.map(({ chunk, time, ignorable }) => ({
chunk,
time,
...ignorable === true ? { ignorable } : {},
}))))) as SessionEvent[],
events: JSON.parse(JSON.stringify(closedChunkLog(entries))) as SessionEvent[],
batchSizes,
}))
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 () => {
const events = packingMatrixLog()
for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) {
@@ -251,8 +217,6 @@ describe('SQLite cross-backend differential behavior', () => {
{ type: 'tool-call-chunks', count: 1 },
],
][partitionIndex])
expect(db.prepare(testSql('count-ignorable-events')).get())
.toEqual({ count: 1 })
} finally {
db.close()
}
@@ -1,3 +0,0 @@
SELECT COUNT(*) AS count
FROM events
WHERE ignorable = 1;
@@ -1,3 +1,3 @@
SELECT COUNT(*) AS count
FROM events
WHERE type = 'text-chunks' AND ignorable = 0;
WHERE type = 'text-chunks' AND is_packed = 1;
@@ -1,6 +1,6 @@
SELECT type, COUNT(*) AS count
FROM events
WHERE type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
AND ignorable = 0
AND is_packed = 1
GROUP BY type
ORDER BY type;
@@ -6,9 +6,9 @@ CREATE TABLE sessions (
);
CREATE TABLE events (
session_id ANY, seq ANY, type ANY, time ANY, data ANY,
source_event_seqs ANY, surface_op ANY, ignorable ANY
source_event_seqs ANY, surface_op ANY, is_packed ANY
);
INSERT INTO persistence_state (singleton, store_id)
VALUES (1, '00000000-0000-4000-8000-000000000000');
PRAGMA application_id = 1146308688;
PRAGMA user_version = 17;
PRAGMA user_version = 18;
@@ -1,2 +1,2 @@
INSERT INTO events (session_id, seq, type, time, data, ignorable)
INSERT INTO events (session_id, seq, type, time, data, is_packed)
VALUES (?, ?, ?, ?, ?, ?);
@@ -8,7 +8,7 @@ CREATE TABLE events (
data TEXT NOT NULL,
source_event_seqs TEXT,
surface_op TEXT,
ignorable INTEGER,
is_packed INTEGER,
PRIMARY KEY (session_id, seq)
);
DROP TABLE strict_events;
@@ -1,4 +1,4 @@
SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, ignorable
SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, is_packed
FROM events
WHERE session_id = ?
ORDER BY seq;
@@ -0,0 +1 @@
PRAGMA user_version = 18;
@@ -135,7 +135,7 @@ async function measureWriteTraffic(
readonly data: string | Uint8Array
readonly source_event_seqs: Uint8Array | null
readonly surface_op: string | null
readonly ignorable: number | null
readonly is_packed: number
}
const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => (
typeof left === 'string' || left === null
@@ -150,7 +150,7 @@ async function measureWriteTraffic(
&& sameValue(left.data, right.data)
&& sameValue(left.source_event_seqs, right.source_event_seqs)
&& left.surface_op === right.surface_op
&& left.ignorable === right.ignorable
&& left.is_packed === right.is_packed
)
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -223,7 +223,7 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
: 1
const next = last.seq + logicalLength
db.prepare(testSql('insert-corrupt-event'))
.run(id, next, 'assistant/chunk', 99, '{not valid json', null)
.run(id, next, 'assistant/chunk', 99, '{not valid json', 0)
db.close()
},
cleanup: async () => { await rm(directory, { recursive: true, force: true }) },
@@ -326,7 +326,7 @@ describe('SessionPersistenceSqlite physical packing', () => {
const db = new DatabaseSync(path)
db.prepare(testSql('insert-corrupt-event'))
.run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), null)
.run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), 0)
db.close()
expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)])
@@ -334,7 +334,7 @@ describe('SessionPersistenceSqlite physical packing', () => {
const malformed = new DatabaseSync(path)
malformed.prepare(testSql('delete-session-events')).run(header.id)
malformed.prepare(testSql('insert-corrupt-event'))
.run(header.id, 0, 'text-chunks', 1, '{not json', 0)
.run(header.id, 0, 'text-chunks', 1, '{not json', 1)
malformed.close()
expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([])
await store.close()
@@ -372,11 +372,11 @@ describe('SessionPersistenceSqlite physical packing', () => {
it('rejects an older SQLite physical schema', async () => {
const path = await freshDbPath('dsh-sqlite-old-schema-')
const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
seed.exec(testSql('set-user-version-16'))
seed.exec(testSql('set-user-version-17'))
seed.close()
await chmod(path, 0o600)
await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS))
.rejects.toThrow(/schema version 16.*incompatible/)
.rejects.toThrow(/schema version 17.*incompatible/)
})
it('rejects a stale physical append without replacing the winning tail', async () => {
@@ -399,7 +399,7 @@ describe('SessionPersistenceSqlite physical packing', () => {
const header = meta(SessionId('stale-repair'))
await stale.appendBatch(header, [chunk(0)], false)
const db = new DatabaseSync(path)
db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', 0)
db.close()
expect((await stale.loadStored(header.id))?.tornMarker).toBe(1)
await winner.commitRepair(header, 1, [])
@@ -531,13 +531,13 @@ describe('SessionPersistenceSqlite schema ownership', () => {
const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-')
const incompatible = new DatabaseSync(incompatiblePath)
incompatible.exec(testSql('set-user-version-16'))
incompatible.exec(testSql('set-user-version-17'))
incompatible.close()
await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/)
const foreignPath = await freshDbPath('dsh-sqlite-foreign-')
const foreign = new DatabaseSync(foreignPath)
foreign.exec(testSql('set-user-version-17'))
foreign.exec(testSql('set-user-version-18'))
foreign.exec(testSql('set-application-id-12345'))
foreign.close()
await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/)
@@ -567,7 +567,7 @@ describe('SessionPersistenceSqlite schema ownership', () => {
it('rejects schema ownership changes observed at mutation time', async () => {
const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
changedVersion.exec(testSql('set-user-version-16'))
changedVersion.exec(testSql('set-user-version-17'))
expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') })
.toThrow(/schema changed before mutation/)
changedVersion.close()
@@ -636,7 +636,7 @@ describe('SessionPersistenceSqlite schema ownership', () => {
const eventRow = {
seq: 0, type: 'turn/start', time: 1, data: '{}',
source_event_seqs: null, surface_op: null, ignorable: null,
source_event_seqs: null, surface_op: null, is_packed: 0,
}
for (const [value, message] of [
[null, /object/],
@@ -645,7 +645,7 @@ describe('SessionPersistenceSqlite schema ownership', () => {
[{ ...eventRow, time: '1' }, /time.*safe integer/],
[{ ...eventRow, data: 1 }, /data.*string or blob/],
[{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/],
[{ ...eventRow, ignorable: 2 }, /ignorable.*0, 1, or null/],
[{ ...eventRow, is_packed: 2 }, /is_packed.*0 or 1/],
] as const) {
expect(() => decodeEventRow(value)).toThrow(message)
}
@@ -755,7 +755,7 @@ describe('SessionPersistenceSqlite edge behavior', () => {
const header = meta('repair-validation')
await store.appendBatch(header, [chunk(0)], false)
const db = new DatabaseSync(path)
db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', 0)
db.close()
await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/)
await store.commitRepair(header, 1, [])
@@ -776,7 +776,7 @@ describe('SessionPersistenceSqlite edge behavior', () => {
await store.appendBatch(header, [chunk(0)], false)
const db = new DatabaseSync(path)
db.prepare(testSql('insert-corrupt-event'))
.run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
.run(header.id, 1, 'assistant/chunk', 2, '{not json', 0)
db.close()
await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/)
@@ -5,7 +5,6 @@ import { readFileSync } from 'node:fs'
export type TestSqlName =
| 'add-unexpected-column'
| 'count-events'
| 'count-ignorable-events'
| 'count-packed-events'
| 'count-physical-types'
| 'create-loose-schema'
@@ -24,6 +23,7 @@ export type TestSqlName =
| 'set-user-version-15'
| 'set-user-version-16'
| 'set-user-version-17'
| 'set-user-version-18'
| 'update-invalid-session-metadata'
/** Load one fixed test SQL resource. */
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
README.md: 00ab8ffc76bac2a87a6ecf9fb3146cc24c7a976d
README.zh.md: 5acc01272685c23039c1f6040c42fcb46c9912b0
README.md: fc47f0c6a6b5fa7ad9eb0f12db1c1baf6684de78
README.zh.md: 6e6982e4f2cb6637d2f657dba88e40a4b7a21f27
@@ -51,7 +51,7 @@ Resume is `load` plus session preparation: the stored log comes back with its he
### 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. 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.
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.
-----
@@ -51,7 +51,7 @@ const headers = await ctx.sessionPersistence.list() // every stored sessi
### 失败与恢复
当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SESSION_FORMAT_VERSION` 保持 v0,本构建不提供格式迁移路径;更高版本会要求操作者升级 harness。解码器只接受下文点名的有限同版本记录变体。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。对仍绑定到活动会话的 id 执行 `load`,会先刷新其快照并在轮次开放时拒绝;冷 load 应用恢复。
当前构建无法忠实解读的存储日志会以方向感知的错误被拒绝,绝不错读。`SESSION_FORMAT_VERSION` 保持 v0,本构建不提供格式迁移路径;更高版本会要求操作者升级 harness。解码器只接受下文点名的有限同版本记录变体。本构建不认识的每个事件类型都会拒绝重建,而已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝[理由](../../../.agents/notes/implemented/simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md)。对仍绑定到活动会话的 id 执行 `load`,会先刷新其快照并在轮次开放时拒绝;冷 load 应用恢复。
-----
@@ -48,10 +48,9 @@ export class SessionPersistenceCorruptionError extends Error {
/**
* The stored log is intact but this runtime cannot faithfully interpret it:
* the header carries an unsupported format version, or an event's type is
* unknown to this build and the event is not marked ignorable. Distinct from
* {@link SessionPersistenceCorruptionError} nothing is damaged; the raw log
* remains readable at {@link location} when the backend keeps one artifact
* per session.
* 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.
*/
export class SessionFormatUnsupportedError extends Error {
/**
@@ -710,8 +709,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
// retired shape this backend refuses to load. The unknown-type guard is
// 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
// the log's next load (trade-off owned by the session-log-version-mechanism
// Agent Note).
// the log's next load (trade-off owned by the fail-closed-session-event-
// vocabulary Agent Note).
assertSupportedEvents(events, id)
if (events.length === 0) return
this.preparations.assertWritable(id)
@@ -1131,19 +1130,16 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
/**
* 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.
* 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.
*/
private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
for (const event of events) {
if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
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`)
}
}
@@ -706,22 +706,21 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
.rejects.toThrow('lacks an identified message')
}
// 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',
// 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',
seq: 0,
time: 1,
data: null,
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 }] })
} 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] })
for (const type of ['user/message', 'assistant/message'] as const) {
const missingContentId = SessionId(`invalid-${type}-without-content`)
@@ -1357,7 +1356,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('rejects an unknown event type on load unless the event is marked ignorable', async () => {
it('rejects an unknown event type on load', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
@@ -1369,16 +1368,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
])
const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error)
expect(failure?.name).toBe('SessionFormatUnsupportedError')
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)
expect(failure?.message).toMatch(/event type "future\/event".*unknown to this harness/)
} finally {
await fiber.dispose()
await fix.cleanup()
@@ -32,7 +32,6 @@ function entry(seq: number): SessionLiveEventEntry {
seq,
time: seq,
data: { seq },
ignorable: true,
} as SessionLiveEventEntry['event'],
}
}
+5 -6
View File
@@ -368,7 +368,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
'',
'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`, 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.',
'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.',
'',
'## Event envelope',
'',
@@ -394,7 +394,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv
/**
* Render the runtime known-vocabulary module: every event type the packages in
* this repo can write, as a generated `ReadonlySet` the read path checks
* unknown-type refusal against (`SessionEvent.ignorable` contract).
* before reconstructing a stored session.
*/
export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string {
const names = [...new Set(events.map(e => e.name))].sort()
@@ -409,10 +409,9 @@ export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string
'/**',
' * Every `SessionEventMap` member declared in this repository — the event',
' * vocabulary this build understands. The persistence read path refuses to',
' * 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.',
' * 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.',
' * Downstream (out-of-repo) plugin events are outside this list by',
' * construction; a registration surface for them is deferred until such a',
' * consumer exists.',