From bef26396e5a16b129b8f0f28b354b0ec3095b045 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:09:16 +0800 Subject: [PATCH] docs(session-projection-cache): cross-version read-compat note and schema-change fixture rule The proposed Agent Note records the three shipped on-disk generations of session_projcache, the read-compat and backup-and-skip decisions, the upgrade matrix, and the rejected alternatives. The package README documents the upgrade guarantees and requires every future schema or domain-version change to land with archived fixtures and tests proving its upgrade story. The storage subsystem page and the generated cordis catalog pick up the new DomainSpec fields. --- ...jcache-cross-version-read-compat.i18n.yaml | 6 ++ ...-02-projcache-cross-version-read-compat.md | 69 +++++++++++++++++++ ...-projcache-cross-version-read-compat.zh.md | 69 +++++++++++++++++++ docs/subsystems/storage.i18n.yaml | 4 +- docs/subsystems/storage.md | 25 ++++++- docs/subsystems/storage.zh.md | 25 ++++++- .../extensions/tool-cordis/src/api-catalog.ts | 8 +-- .../session-projection-cache/README.i18n.yaml | 4 +- .../session-projection-cache/README.md | 3 + .../session-projection-cache/README.zh.md | 3 + 10 files changed, 206 insertions(+), 10 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md create mode 100644 .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml new file mode 100644 index 0000000000..412b47ab8f --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md +2026-09-02-projcache-cross-version-read-compat.md: 9bd0cd9049ed9c9d4180f54d55f10f9b0f555dfc +2026-09-02-projcache-cross-version-read-compat.zh.md: 4682c5fe4732b21649214ee98f25d478c9bfcb18 diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md new file mode 100644 index 0000000000..9bd0cd9049 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.md @@ -0,0 +1,69 @@ +# Agent Note: Projection-cache cross-version read compatibility (session_projcache v3/v4/v5) + +Status: proposed + +English | [中文](2026-09-02-projcache-cross-version-read-compat.zh.md) + +## Problem + +The `session_projcache` storage domain evolved through three on-disk generations across published releases. An upgraded DSH_HOME failed in two ways: + +- **A v3 single-file home bricked startup after the upgrade**: the per-record layout's legacy bootstrap migrated the old whole-unit file without checking its `unit.version`, stamping the old records with the current version into the new tree; the domain layer's per-record zod validation at open then hit the missing now-required fields → `invalid-record` → the whole domain refused to open → the plugin tree failed to load. And because the bootstrap writes before validation runs, **the first boot permanently wrote the bad documents into the new tree** ("poisoning") — every later boot saw a non-empty tree, never took the legacy path again, and the home stayed unusable. +- **A v4 per-record home lost its listing titles after the upgrade**: v4 documents were silently discarded by the version-stamp check (the per-record contract), and SessionList is a zero-I/O cache-only read, so a miss served the row without projections; titles only returned as each session was individually reopened. + +The cache domain's own contract is "a stale or unreadable cache costs a longer tail replay, never a wrong value, never a refused load" — the hard failure and the wholesale discard each broke the first half of that contract or the product expectation. + +## The three on-disk generations + +| domain version | shipped in | layout | on-disk form | identity fields | row fields | +|---|---|---|---|---|---| +| 3 | 0.1.1-rc.2 | single | one file `storages/session_projcache.json` (`{unit:{name,version}, global, tables}`) | `createdAt`, `cwd?` | `ver`, `seq`, `val` | +| 4 | 0.1.2-alpha.3 | per-record | one file per session `storages/session_projcache/sessions/.json` (`{version, record}`) | `createdAt`, `cwd?` | same | +| 5 | 0.1.2-alpha.4 | per-record | same as v4 | + `isSeeded` (required → optional in this change), `inheritedEventCount` (same) | same (`seq` numbers mean the same as v4; only type brands were added) | + +The only substantive v4→v5 difference is the two new lineage identity fields; the `ver/seq/val` row shape is identical across all three generations, and `seq` numbering did not change ([the 2026-08-31 seq/offset brands note](../../implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.md) pins the on-disk numbers as unchanged). v3→v4 was a layout migration with identical record content. + +One derived shape also exists: a v3 home that ran the v5 build once (the poisoned state) — its new tree holds documents **stamped 5 whose content is a v3 record** (no lineage fields). + +## Proposal + +Declared read compatibility — reads tolerate vouched-for older versions, writes always stamp the current one: + +1. **`DomainSpec.compatibleVersions` (new, optional)**: the domain owner declares "records stored under these older versions are also readable under the current record schemas" (typically by declaring the fields old records lack as optional). `defineDomain` validates each entry as a non-negative integer below the current version; `descriptorOf` projects the set onto the backend `KvUnitDescriptor`. +2. **json backend per-record reads** accept version stamps in "current ∪ compatibleVersions"; anything outside the set is still discarded as foreign. **The write path always stamps the current version** (the first checkpoint after reading an old record naturally advances it). The `single` layout stays exact-version. +3. **Legacy-bootstrap version gate (the actual bug fix)**: the old whole-unit file's `unit.version` must fall inside the accepted set to be migrated; otherwise the file is left alone and the unit reads empty — stamping records the owner never vouched for turns a discardable stale cache into hard schema failures at the domain layer. +4. **The projcache domain declares `version: 5, compatibleVersions: [3, 4]`**, and the two lineage fields become `.optional()`. The single reader of stored identities, `identityMatches`, normalizes absence to the unseeded lineage (`?? false` / `?? 0`): exact for unforked sessions, while a forked session's expectation is seeded → natural mismatch → discard and cold rebuild, so the lineage binding loses none of its protection. +5. **The poisoned state self-heals**: documents stamped 5 without lineage fields parse under the optional schema (their content is the real pre-upgrade cache data), so the home boots again and titles serve immediately. +6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `.json.bak.`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). + +### Upgrade matrix + +| home shape | behavior after the fix | +|---|---| +| v3 single-file (not poisoned) | bootstrap migrates (3 ∈ accepted set) → titles serve immediately | +| v3 + poisoned new tree | new-tree documents read directly (optional tolerance) → boot restored, titles serve immediately | +| v4 per-record | documents read directly (4 ∈ accepted set) → titles serve immediately | +| v5 healthy | unaffected | +| old records of forked (seeded) sessions | identity mismatch → discarded, cold rebuild when the session opens (safe side) | + +## Alternatives considered + +- **Discard-and-rebuild only** (bootstrap gate + bump to v6): fixes the boot, but every SessionList title is lost after the upgrade until each session is reopened — fails the upgrade-and-go product requirement. +- **Schema `.default()` fills**: behaviorally equivalent to optional + reader normalization, but bakes the "absent = unseeded" interpretation into the durable schema's output type; ruled for optional — the schema honestly describes every accepted on-disk shape and the interpretation lives at the consumer (user ruling, 2026-09-02). +- **Roll the domain version back 5→4**: the smallest diff (three lines), but breaks version monotonicity, depends on the "bootstrap skips no versions" bug itself, and drops every poisoned and healthy v5 home's cache. + +## Risks + +- A deployment routing this domain to the sqlite backend gets none of the tolerance: sqlite implements neither `compatibleVersions` nor `backupRecord`, so behavior degrades to the old strict-version semantics (a whole-unit version mismatch still refuses with `version-mismatch`; nothing loosens, nothing serves wrong values). Shipped compositions route this domain to json, so this stays a deployment-configuration risk only. +- The optional lineage fields widen what a current-version document may omit: a v5-stamped record stripped of its lineage decodes as unseeded. The identity match still refuses it for seeded callers, and the per-row `ver` guard still screens every value, so the residual exposure is an unseeded caller reading an unseeded-shaped record — the same trust extended to genuine pre-lineage records. +- `backupRecord` overwrites a same-minute backup of the same key (the newer bytes win); distinct minutes and distinct keys never collide. + +## Acceptance criteria + +- `storage-json` unit tests: compat-stamped reads / out-of-set discards / writes stamping current; legacy bootstrap migrating only accepted versions (including the migrated-documents-stamp-current assertion); `backupRecord` move / absent read / rewrite / closed guard. +- `storage-domain` unit tests: `compatibleVersions` / `invalidRecords` declaration validation; backup-and-skip falling back to fail-loud when the backend has no `backupRecord`. +- `session-projection-cache` unit tests: records without lineage fields serve unseeded sessions verbatim and are discarded for seeded ones. +- **Archived-fixture recovery tests** (`tests/fixtures.spec.ts` + `tests/fixtures/`): four media archives produced by the real released builds — `v3-single-unit.json` (the 0.1.1-rc.2 whole-unit file), `v4-session-doc.json` (0.1.2-alpha.3), `v5-session-doc.json` (current), `v5-lineageless-doc.json` (the unguarded bootstrap's poisoned shape, synthesized from the v3 record) — each opened through the real storage stack, asserting the listing serves the archived title and that a live write rewrites the document to the current version (v5 stamp + lineage fields + fresh value); plus the backup-and-skip behavior for a schema-failing record (boot survives, `.bak` lands, log is concrete, neighbor records unharmed). +- End-to-end acceptance: `scripts/releasefix/` (real old release artifacts building the v3 / v4 / poisoned homes; the SessionList RPC asserts titles restored verbatim). + +Future bump procedure: when a new version's shape can tolerate old records through "optional fields + reader normalization", add the old version to `compatibleVersions`; otherwise bump normally (discard and rebuild) and remove the no-longer-compatible versions from the set. Either way, the package README requires the bump to land with archived fixtures and tests proving the chosen disposition. diff --git a/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md new file mode 100644 index 0000000000..4682c5fe47 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md @@ -0,0 +1,69 @@ +# Agent Note: 投影缓存跨版本读兼容(session_projcache v3/v4/v5) + +Status: proposed + +[English](2026-09-02-projcache-cross-version-read-compat.md) | 中文 + +## 问题 + +`session_projcache` 存储域在已发布版本间演进了三代磁盘结构。升级后的 DSH_HOME 出现两类故障: + +- **v3 单文件 home 升级后启动硬失败**:per-record 布局的 legacy bootstrap 迁移旧单文件时不检查其 `unit.version`,把旧记录原样打上当前版本戳写入新树;domain 层开域时逐条 zod 校验,旧记录缺新增必填字段 → `invalid-record` → 整个域拒开 → 插件树加载失败。且 bootstrap 先写盘后校验,**首次启动即把坏文档永久写入新树**("投毒")——此后每次启动新树非空、连 legacy 路径都不再走,home 持续不可用。 +- **v4 per-record home 升级后列表丢标题**:v4 文档被版本戳检查静默丢弃(per-record 契约),SessionList 是零 I/O 纯缓存读,miss 后整行不带投影;标题要等每个会话被逐个重新打开后才恢复。 + +缓存域自身的契约是"过期或不可读的缓存只付出更长的尾部重放,绝不给出错值、绝不拒载"——硬失败与整体丢弃都违背该契约的前半句或产品预期。 + +## 三代磁盘结构差异 + +| domain version | 携带发布 | 布局 | 磁盘形态 | identity 字段 | 行字段 | +|---|---|---|---|---|---| +| 3 | 0.1.1-rc.2 | single | 单文件 `storages/session_projcache.json`(`{unit:{name,version}, global, tables}`) | `createdAt`, `cwd?` | `ver`, `seq`, `val` | +| 4 | 0.1.2-alpha.3 | per-record | 每会话一份 `storages/session_projcache/sessions/.json`(`{version, record}`) | `createdAt`, `cwd?` | 同上 | +| 5 | 0.1.2-alpha.4 | per-record | 同 v4 | + `isSeeded`(必填→本次改 optional)、`inheritedEventCount`(同) | 同上(`seq` 数值语义与 v4 相同,仅类型加 brand) | + +v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;行内 `ver/seq/val` 三代一致,`seq` 的数值含义未变([2026-08-31 seq/offset brands note](../../implemented/architecture/2026-08-31-session-sequence-and-log-offset-brands.zh.md) 明确 on-disk 数值不变)。v3→v4 是布局迁移,记录内容结构一致。 + +另有一种衍生形态:跑过一次 v5 版本的 v3 home(投毒态)——新树里存在**版本戳为 5 但内容是 v3 记录**(缺 lineage 字段)的文档。 + +## 提案 + +声明式读兼容——读容忍 owner 背书过的旧版本,写恒戳当前版本: + +1. **`DomainSpec.compatibleVersions`(新增,可选)**:域 owner 声明"这些旧版本的存量记录在当前记录 schema 下也可读"(典型手段:新增字段标 optional)。`defineDomain` 校验各项为小于当前 version 的非负整数;`descriptorOf` 透传到后端 `KvUnitDescriptor`。 +2. **json 后端 per-record 读**:接受"当前版本 ∪ compatibleVersions"内的版本戳,集合外照旧视为 foreign 丢弃;**写路径永远戳当前版本**(读到旧记录后的下一次 checkpoint 自然把它推进到当前版本)。single 布局维持 exact-version 不变。 +3. **legacy bootstrap 版本把关(bug 修复本体)**:旧单文件的 `unit.version` 必须落在接受集合内才迁移,否则视为空 unit 留在原地——为 owner 未背书的记录打当前版本戳,会把"可丢弃的过期缓存"变成 domain 层的 schema 硬失败。 +4. **projcache 域声明 `version: 5, compatibleVersions: [3, 4]`**;两个 lineage 字段改为 `.optional()`。唯一消费 stored identity 的读点 `identityMatches` 把缺失归一化为 unseeded lineage(`?? false` / `?? 0`):对非 fork 会话这是精确值;fork 会话的 expected 是 seeded → 天然 mismatch → 丢弃冷读重建,lineage 绑定的防护不放松。 +5. **投毒态自愈**:v5 戳缺 lineage 字段的文档被 optional schema 直接接受(内容本就是升级前的真实缓存数据),home 恢复可启动且标题立即可服务。 +6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `.json.bak.`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。 + +### 升级矩阵 + +| home 形态 | 修复后行为 | +|---|---| +| v3 单文件(未投毒) | bootstrap 迁移(3 ∈ 接受集)→ 标题立即可服务 | +| v3 + 投毒新树 | 新树文档直接读入(optional 容忍)→ 启动恢复、标题立即可服务 | +| v4 per-record | 文档直接读入(4 ∈ 接受集)→ 标题立即可服务 | +| v5 正常 | 不受影响 | +| fork(seeded)会话的旧记录 | identity mismatch → 丢弃,打开会话时冷读重建(安全侧) | + +## 备选方案 + +- **只丢弃重建**(bootstrap 把关 + bump v6):启动可修,但升级后 SessionList 标题全丢、要逐会话打开才恢复——不满足升级即用的产品要求。 +- **schema `.default()` 填缺省**:行为与 optional+读点归一化等价,但把"缺失=unseeded"的解释固化进 durable schema 的输出类型;拍板为 optional——schema 如实描述介质上所有被接受的形态,解释权在消费点(2026-09-02 用户裁决)。 +- **域版本回退 5→4**:改动最小(三行),但破坏版本单调性、依赖"bootstrap 不查版本"这个 bug 本身、且投毒态与正常 v5 home 的缓存全被丢弃。 + +## 风险 + +- 部署方若把本域路由到 sqlite 后端,得不到任何容忍能力:sqlite 既未实现 `compatibleVersions` 也没有 `backupRecord`,行为退化为原有的严格版本语义(整 unit 版本不匹配仍 `version-mismatch` 拒开;不放松、不出错值)。shipped 组合固定路由 json,此风险仅存在于部署配置层面。 +- optional lineage 字段放宽了当前版本文档可缺省的范围:被剥离 lineage 的 v5 戳记录会解码为 unseeded。身份比对仍会对 seeded 调用方拒收,逐行 `ver` 守卫仍筛查每个值,残余暴露面只是 unseeded 调用方读到 unseeded 形态的记录——与真实 pre-lineage 记录享有的信任完全相同。 +- `backupRecord` 对同一键的同一分钟内重复备份会覆盖前一份(新字节胜出);不同分钟、不同键永不冲突。 + +## 验收标准 + +- `storage-json` 单测:compat 版本戳读入/集合外丢弃/写恒当前版本;legacy bootstrap 仅在版本被接受时迁移(含迁移后文档戳当前版本断言);`backupRecord` 移档/读缺席/重写/封闭守卫。 +- `storage-domain` 单测:`compatibleVersions`/`invalidRecords` 声明校验;后端无 `backupRecord` 时 backup-and-skip 回退 fail-loud。 +- `session-projection-cache` 单测:缺 lineage 字段的记录对 unseeded 会话按原值服务、对 seeded 会话丢弃。 +- **归档 fixtures 独立恢复测试**(`tests/fixtures.spec.ts` + `tests/fixtures/`):真实发布物产出的四份介质存档——`v3-single-unit.json`(0.1.1-rc.2 整域单文件)、`v4-session-doc.json`(0.1.2-alpha.3)、`v5-session-doc.json`(当前版)、`v5-lineageless-doc.json`(无守卫 bootstrap 的投毒形态,由 v3 记录合成)——逐一走真实存储栈开域,断言列表读出归档标题、且 live 写把文档重写为当前版本(v5 戳 + lineage 字段 + 新值);外加 schema 失败记录的 backup-and-skip 行为(启动不失败、`.bak` 落盘、日志具体、邻居记录不受累)。 +- 端到端验收:`scripts/releasefix/`(真实老版本发布包构造 v3/v4/投毒三态 home,SessionList RPC 断言标题原样恢复)。 + +未来 bump 流程:新版本结构若可用"optional 字段 + 读点归一化"容忍旧记录,就把旧版本加入 `compatibleVersions`;否则正常 bump(丢弃重建),并把不再兼容的版本从集合中移除。无论哪条路,包 README 都要求 bump 随附归档 fixture 和论证所选处置方式的测试。 diff --git a/docs/subsystems/storage.i18n.yaml b/docs/subsystems/storage.i18n.yaml index 1e9a7e221c..d234297a9d 100644 --- a/docs/subsystems/storage.i18n.yaml +++ b/docs/subsystems/storage.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/storage.md -storage.md: 1e4141e6ef1c6f8e1c2593e21e788b626d6b1ed7 -storage.zh.md: f0433c600674741c3de0ce3e99430297839ce124 +storage.md: e52f6d8869ee3092a5be99f0bd2873b410b45ed9 +storage.zh.md: f752862af9f10cfb32f39f2a1aa7801c922f841e diff --git a/docs/subsystems/storage.md b/docs/subsystems/storage.md index 1e4141e6ef..e52f6d8869 100644 --- a/docs/subsystems/storage.md +++ b/docs/subsystems/storage.md @@ -65,6 +65,26 @@ interface DomainSpec { * (a stale record document is discarded, never migrated). */ readonly layout?: 'single' | 'per-record' + /** + * Older domain versions whose stored records the current record schemas + * also accept (the declaring owner vouches for that, typically by + * declaring the fields older records lack as optional). `per-record` backends + * read documents stamped with a listed version instead of discarding them, + * and accept a legacy whole-unit file so stamped for the one-time + * bootstrap; writes always stamp {@link version}. + */ + readonly compatibleVersions?: readonly number[] + /** + * What `open` does with a stored table record that fails its zod schema. + * Absent (the default), the whole open rejects with `invalid-record` — + * right for authoritative data. `'backup-and-skip'` is for domains whose + * records are disposable derived data: the backend moves the record's + * document aside (`KvUnit.backupRecord`), the failure is logged with + * its cause, and the open continues with the record absent. A backend + * without `backupRecord` (no per-record document to move) falls back + * to the rejecting default. The global slot always rejects. + */ + readonly invalidRecords?: 'backup-and-skip' /** Optional global singleton slot. */ readonly global?: DomainGlobalSpec /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */ @@ -180,7 +200,10 @@ The mounted domain facility. Opens declared domains over routed backends; one fa * (`facet-unsupported`); open the unit projected from the spec (backend * `version-mismatch`/`malformed-medium` pass through); load and validate * every stored record against the spec's zod schemas (`invalid-record` - * with the offending table and key); construct the domain. + * with the offending table and key — unless the spec declares + * `invalidRecords: 'backup-and-skip'` and the unit can move documents aside, in + * which case the failing record is backed up, logged, and skipped); + * construct the domain. * * Lifecycle: the CALLER owns the returned handle and closes it via * `Domain.close()` (typically as its own `ctx.effect` disposer) — the diff --git a/docs/subsystems/storage.zh.md b/docs/subsystems/storage.zh.md index f0433c6006..f752862af9 100644 --- a/docs/subsystems/storage.zh.md +++ b/docs/subsystems/storage.zh.md @@ -65,6 +65,26 @@ interface DomainSpec { * (a stale record document is discarded, never migrated). */ readonly layout?: 'single' | 'per-record' + /** + * Older domain versions whose stored records the current record schemas + * also accept (the declaring owner vouches for that, typically by + * declaring the fields older records lack as optional). `per-record` backends + * read documents stamped with a listed version instead of discarding them, + * and accept a legacy whole-unit file so stamped for the one-time + * bootstrap; writes always stamp {@link version}. + */ + readonly compatibleVersions?: readonly number[] + /** + * What `open` does with a stored table record that fails its zod schema. + * Absent (the default), the whole open rejects with `invalid-record` — + * right for authoritative data. `'backup-and-skip'` is for domains whose + * records are disposable derived data: the backend moves the record's + * document aside (`KvUnit.backupRecord`), the failure is logged with + * its cause, and the open continues with the record absent. A backend + * without `backupRecord` (no per-record document to move) falls back + * to the rejecting default. The global slot always rejects. + */ + readonly invalidRecords?: 'backup-and-skip' /** Optional global singleton slot. */ readonly global?: DomainGlobalSpec /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */ @@ -180,7 +200,10 @@ The mounted domain facility. Opens declared domains over routed backends; one fa * (`facet-unsupported`); open the unit projected from the spec (backend * `version-mismatch`/`malformed-medium` pass through); load and validate * every stored record against the spec's zod schemas (`invalid-record` - * with the offending table and key); construct the domain. + * with the offending table and key — unless the spec declares + * `invalidRecords: 'backup-and-skip'` and the unit can move documents aside, in + * which case the failing record is backed up, logged, and skipped); + * construct the domain. * * Lifecycle: the CALLER owns the returned handle and closes it via * `Domain.close()` (typically as its own `ctx.effect` disposer) — the diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 55761bf933..3c81eb2b3b 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2151,7 +2151,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async open(spec: S): Promise>', - description: 'Open one declared domain. Steps, each failing the whole call: reject a name that is already open (`already-open`); resolve the backend route (`backend-not-found` passes through from the hub); require its `kv` facet (`facet-unsupported`); open the unit projected from the spec (backend `version-mismatch`/`malformed-medium` pass through); load and validate every stored record against the spec\'s zod schemas (`invalid-record` with the offending table and key); construct the domain.\n\nLifecycle: the CALLER owns the returned handle and closes it via `Domain.close()` (typically as its own `ctx.effect` disposer) — the facility does not tie the domain to any consumer fiber. Domains still open when the facility unmounts are closed by the plugin disposer.', + description: 'Open one declared domain. Steps, each failing the whole call: reject a name that is already open (`already-open`); resolve the backend route (`backend-not-found` passes through from the hub); require its `kv` facet (`facet-unsupported`); open the unit projected from the spec (backend `version-mismatch`/`malformed-medium` pass through); load and validate every stored record against the spec\'s zod schemas (`invalid-record` with the offending table and key — unless the spec declares `invalidRecords: \'backup-and-skip\'` and the unit can move documents aside, in which case the failing record is backed up, logged, and skipped); construct the domain.\n\nLifecycle: the CALLER owns the returned handle and closes it via `Domain.close()` (typically as its own `ctx.effect` disposer) — the facility does not tie the domain to any consumer fiber. Domains still open when the facility unmounts are closed by the plugin disposer.', parameters: [{ name: 'spec', description: 'The domain declaration, typically from `defineDomain`.' }], returns: 'the opened domain handle, typed by the spec.', }, @@ -3956,7 +3956,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'DomainSpec', - declaration: 'export interface DomainSpec {\n readonly name: string;\n readonly version: number;\n readonly layout?: \'single\' | \'per-record\';\n readonly global?: DomainGlobalSpec;\n readonly tables: Record;\n}', + declaration: 'export interface DomainSpec {\n readonly name: string;\n readonly version: number;\n readonly layout?: \'single\' | \'per-record\';\n readonly compatibleVersions?: readonly number[];\n readonly invalidRecords?: \'backup-and-skip\';\n readonly global?: DomainGlobalSpec;\n readonly tables: Record;\n}', }, { name: 'DomainTableSpec', @@ -4264,11 +4264,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'KvUnit', - declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', + declaration: 'export interface KvUnit {\n loadAll(): Promise<{\n tables: Record>;\n global: unknown;\n }>;\n putRecord(table: string, key: string, value: unknown): Promise;\n deleteRecord(table: string, key: string): Promise;\n backupRecord?(table: string, key: string): Promise;\n setGlobal(value: unknown): Promise;\n close(): Promise;\n}', }, { name: 'KvUnitDescriptor', - declaration: 'export interface KvUnitDescriptor {\n readonly name: string;\n readonly version: number;\n readonly tables: readonly string[];\n readonly hasGlobal: boolean;\n readonly layout?: \'single\' | \'per-record\';\n}', + declaration: 'export interface KvUnitDescriptor {\n readonly name: string;\n readonly version: number;\n readonly tables: readonly string[];\n readonly hasGlobal: boolean;\n readonly layout?: \'single\' | \'per-record\';\n readonly compatibleVersions?: readonly number[];\n}', }, { name: 'LlmAdapter', diff --git a/packages/session/session-projection-cache/README.i18n.yaml b/packages/session/session-projection-cache/README.i18n.yaml index e4fe8490a2..4dd1489d4e 100644 --- a/packages/session/session-projection-cache/README.i18n.yaml +++ b/packages/session/session-projection-cache/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md -README.md: 51b9d86724af96304cf09a5c7c1b61b7394d336a -README.zh.md: bb84b67bdde678884fd4f2be1b14b2161da8c2a0 +README.md: 9fd9766d75ab3f9a460b2802ac59810839f21ae4 +README.zh.md: 98725d9ce76ab44821adf3d43e3807932cf6b675 diff --git a/packages/session/session-projection-cache/README.md b/packages/session/session-projection-cache/README.md index 51b9d86724..9fd9766d75 100644 --- a/packages/session/session-projection-cache/README.md +++ b/packages/session/session-projection-cache/README.md @@ -64,6 +64,8 @@ Three mandatory points always write: session creation persists the seed-derived The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row initialized under one fork cut cannot seed another. The JSON backend stores each record at `/session_projcache/sessions/.json` in an owner-only directory tree. +Upgrades never cost the boot or the listing: records stamped with a version in the spec's `compatibleVersions` stay readable (their absent lineage fields decode as the unseeded lineage — exact for unseeded sessions, while a seeded caller fails the identity match and refolds cold), and a stored record that still fails schema validation is moved aside as `.json.bak.` under the domain's `invalidRecords: 'backup-and-skip'` policy, logged with its cause, and rebuilt by the next checkpoint. + ----- @@ -126,6 +128,7 @@ These limits define where the cache needs operational care. They are current pac - **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself. - **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window. - **No cache-side cold refold** — the cache serves and refreshes its rows but never reads the session log (it does not depend on the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself. +- **Every schema or domain-version change must prove its upgrade story** — a change to the stored record schema or the domain version lands in the same PR with an archived fixture of the previously shipped on-disk format under `tests/fixtures/` and test cases in `tests/fixtures.spec.ts` proving the chosen disposition: read-compat recovery (`compatibleVersions`), current-version rewrite, or backup-and-skip salvage. A bump whose old records are simply discarded still proves that the discard neither fails the boot nor poisons the tree. ### Dev Note diff --git a/packages/session/session-projection-cache/README.zh.md b/packages/session/session-projection-cache/README.zh.md index bb84b67bdd..98725d9ce7 100644 --- a/packages/session/session-projection-cache/README.zh.md +++ b/packages/session/session-projection-cache/README.zh.md @@ -64,6 +64,8 @@ kind: "package-reference" 日志领先,缓存跟随:实时检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配实时单元 schema 与完整生命周期身份(`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此在一个 fork 切点下初始化的行不能播种另一个切点。JSON 后端把每条记录存于仅所有者可访问的 `/session_projcache/sessions/.json` 目录树中。 +升级绝不拖垮启动或列表:版本戳落在 spec `compatibleVersions` 集合内的记录保持可读(缺失的 lineage 字段解码为 unseeded lineage——对非 fork 会话精确无误,seeded 调用方则通不过身份比对、回落冷折叠),而仍然通不过 schema 校验的存量记录会按域的 `invalidRecords: 'backup-and-skip'` 策略移出为 `.json.bak.<时间戳>`、连同原因写入日志,并由下一次检查点重建。 + ----- @@ -126,6 +128,7 @@ kind: "package-reference" - **无淘汰或保留接口**——记录按会话持续累积;清理已存储检查点属于带外维护,与会话持久化采用相同策略。 - **间隔节流采用按会话的粗粒度控制**——一次无脏数据的写入完成后,计时器在首个脏事件到达时启动;持续但低于条数阈值的事件流每间隔写入一次,而非滑动窗口。 - **缓存侧不做冷重折叠**——缓存只服务并刷新自己的记录,从不读取会话日志,因为它不依赖持久化层;需要保证冷快照的消费方自行从日志重新折叠。 +- **每次 schema 或域版本变更都必须论证升级路径**——改动存储记录 schema 或域版本时,同一 PR 必须在 `tests/fixtures/` 下归档此前已发布的磁盘格式样本,并在 `tests/fixtures.spec.ts` 中用测试论证所选的处置方式:读兼容恢复(`compatibleVersions`)、当前版本重写,或 backup-and-skip 抢救。即便选择直接丢弃旧记录的 bump,也要证明丢弃既不炸启动、也不污染缓存树。 ### 开发备注