소스 검색

Merge pull request #2781 from deepseek-harness/xtr/projection-per-session-cache

feat(session-projection-cache): per-session checkpoint documents on a per-record storage layout
_Kerman 1 개월 전
부모
커밋
53c8f64eed
72개의 변경된 파일과 1380개의 추가작업 그리고 571개의 파일을 삭제
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml
  2. 31 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
  3. 31 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md
  4. 2 2
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml
  5. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md
  6. 1 1
      .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md
  7. 2 2
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
  8. 1 1
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
  9. 1 1
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
  10. 12 0
      apps/cli/composition.md
  11. 4 4
      apps/web/tests/agent-preset-selection.e2e.ts
  12. 29 11
      apps/web/tests/subagent-conversation.e2e.ts
  13. 2 2
      docs/config-catalog.i18n.yaml
  14. 7 6
      docs/config-catalog.md
  15. 7 6
      docs/config-catalog.zh.md
  16. 2 2
      docs/event-producer-consumer.i18n.yaml
  17. 1 1
      docs/event-producer-consumer.md
  18. 1 1
      docs/event-producer-consumer.zh.md
  19. 2 2
      docs/module-graph.i18n.yaml
  20. 1 2
      docs/module-graph.md
  21. 1 2
      docs/module-graph.zh.md
  22. 2 2
      docs/subsystems/session-projection.i18n.yaml
  23. 21 20
      docs/subsystems/session-projection.md
  24. 21 20
      docs/subsystems/session-projection.zh.md
  25. 2 2
      docs/subsystems/storage.i18n.yaml
  26. 8 0
      docs/subsystems/storage.md
  27. 8 0
      docs/subsystems/storage.zh.md
  28. 2 2
      packages/bundle/acp-app/README.i18n.yaml
  29. 1 1
      packages/bundle/acp-app/README.md
  30. 1 1
      packages/bundle/acp-app/README.zh.md
  31. 27 0
      packages/bundle/base/cordis.patch.yml
  32. 4 0
      packages/bundle/base/package.json
  33. 2 2
      packages/bundle/headless/README.i18n.yaml
  34. 1 1
      packages/bundle/headless/README.md
  35. 1 1
      packages/bundle/headless/README.zh.md
  36. 2 2
      packages/bundle/sdk-app/README.i18n.yaml
  37. 1 1
      packages/bundle/sdk-app/README.md
  38. 1 1
      packages/bundle/sdk-app/README.zh.md
  39. 2 2
      packages/bundle/web-app/README.i18n.yaml
  40. 0 0
      packages/bundle/web-app/README.md
  41. 0 0
      packages/bundle/web-app/README.zh.md
  42. 0 19
      packages/bundle/web-app/cordis.patch.yml
  43. 0 4
      packages/bundle/web-app/package.json
  44. 3 3
      packages/credentials/credentials-local/tests/migration.spec.ts
  45. 9 9
      packages/extensions/tool-cordis/src/api-catalog.ts
  46. 2 2
      packages/session/session-projection-cache/README.i18n.yaml
  47. 18 17
      packages/session/session-projection-cache/README.md
  48. 18 17
      packages/session/session-projection-cache/README.zh.md
  49. 5 6
      packages/session/session-projection-cache/package.json
  50. 65 77
      packages/session/session-projection-cache/src/index.ts
  51. 2 2
      packages/session/session-projection-cache/src/invariant.ts
  52. 17 13
      packages/session/session-projection-cache/src/spec.ts
  53. 256 230
      packages/session/session-projection-cache/tests/cache.spec.ts
  54. 7 7
      packages/session/session-projection-cache/tsconfig.json
  55. 17 0
      packages/storage/storage-domain/src/spec.ts
  56. 12 1
      packages/storage/storage-domain/tests/domain.spec.ts
  57. 2 2
      packages/storage/storage-json/README.i18n.yaml
  58. 9 3
      packages/storage/storage-json/README.md
  59. 9 3
      packages/storage/storage-json/README.zh.md
  60. 40 1
      packages/storage/storage-json/src/format.ts
  61. 13 8
      packages/storage/storage-json/src/index.ts
  62. 275 0
      packages/storage/storage-json/src/per-record-unit.ts
  63. 16 9
      packages/storage/storage-json/src/single-unit.ts
  64. 194 1
      packages/storage/storage-json/tests/json-backend.spec.ts
  65. 12 1
      packages/storage/storage/src/backend.ts
  66. 1 0
      packages/subagent/subagent/package.json
  67. 15 6
      packages/subagent/subagent/tests/list-children.spec.ts
  68. 44 7
      packages/test-support/session-snapshot/src/normalize.ts
  69. 47 2
      packages/test-support/session-snapshot/tests/normalize.spec.ts
  70. 18 15
      pnpm-lock.yaml
  71. 1 0
      scripts/run-gates.spec.ts
  72. 1 1
      snapshots/sdk/subagent-mixed/session.jsonl

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
+2026-08-19-projection-cache-per-session-files.md: 9e102e786a6c06d82d1a0f45cc2f96a50c8abcd8
+2026-08-19-projection-cache-per-session-files.zh.md: d875c3f57800936f66fbf65233637df9bf300e2d

+ 31 - 0
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md

@@ -0,0 +1,31 @@
+# Agent Note: Projection cache as per-session files
+
+Status: implemented
+
+English | [中文](2026-08-19-projection-cache-per-session-files.zh.md)
+
+## Problem
+
+The persisted projection cache was one global `session_projcache.json` — a `sessions` table in a single file at the storage root. Every throttled checkpoint rewrote the whole file containing every session's rows, so write amplification grew with session count, and one malformed file took the entire cache down at once.
+
+## Decision
+
+The cache opens the `session_projcache` storage domain in the new `per-record` layout, added to the json backend: one version-stamped document per session at `<root>/session_projcache/sessions/<id>.json`, owned by the storage stack — `storage` / `storage-json` / `storage-domain` live in the shared base bundle alongside the cache, and the cache itself is a plain domain consumer again. Every shipped base-backed profile keeps the cache enabled, so the session producer records checkpoints independently of whether its current application exposes a listing interface; `sdk-minimal`, which does not use the base bundle, remains outside this composition. The cache never consults the persistence layer: no `locate`, no dependency on which backend is mounted.
+
+Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a synchronous lookup in the domain's in-memory tables (zero I/O), and every write queues on the domain's per-unit write chain, mutating memory only after durability — no direct disk reads that could lag the throttled writes. The cache keeps every other responsibility: checkpoint fold, write policy (turn/end + disposal mandatory, count/interval throttle), fail-soft durability, and the listing read. `cachedSnapshot(meta)` is synchronous. The cache runs no cold-refold ladder (that would require reading the session log, which belongs to the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself. The json backend creates its tree owner-only (`0o700`).
+
+## Consequences
+
+- Per-session write isolation: each throttled write replaces only that session's small document, removing the global write amplification. The domain write chain serializes writes, so a newer cut never lands before an older one; domain close drains in-flight writes.
+- Listing is a synchronous in-memory read; a session without a record document simply lacks the projection column.
+- ACP, headless, SDK, and Web sessions publish cache rows for later consumers. The log-leading durability barrier may flush a covered prefix at the cache cadence and split otherwise coalesced physical JSONL runs; recorded profile snapshots re-pack the logical event stream so cache timing does not define fixture layout.
+- The per-record contract scopes failure: a malformed or stale-version document reads as an absent record at open, so one bad file never bricks the cache, and a checkpoint schema bump discards stale sessions per record instead of rejecting the whole domain.
+- The json backend bootstraps the per-record tree from the legacy whole-unit cache only when enumeration finds no new-layout document path. Any new document path, including an unreadable or stale file, suppresses the bootstrap for the whole unit; missing session rows refold from the log. The legacy file remains untouched.
+- The cache record is bound to the same log lifecycle as before: the stored `{createdAt, cwd}` identity guards against a recreated id.
+
+## Alternatives considered
+
+- **Keep the global sessions table.** Preserves one-load listing, but keeps the global write amplification and single-file blast radius that motivated the change.
+- **Cache-owned per-session files** (`<root>/<session-id>/projection_cache.json`, the first revision of this change). Tried and reverted in review: the cache hand-rolled the medium — paths, per-path write chains, in-flight tracking, owner-only file modes, and a sqlite no-path special case — and its listing read hit the disk directly on every call while writes were throttled, so reads and writes were never consistent.
+- **Resolve the path through `sessionPersistence.locate(meta)`** (the file beside the session log). Rejected: the cache would have to guess "beside the log" from a log artifact path (`dirname` + fixed filename), coupling the cache to the persistence service and to a backend's layout.
+- **Make `per-record` a mode of the existing unit instead of a separate unit class.** Rejected: the two layouts have genuinely different state models — `single` is memory-authoritative with whole-file publish, `per-record` is stateless (the directory is the state; `loadAll` re-reads the tree) — so they are separate small classes behind one backend, with record keys validated path-safe instead of encoded.

+ 31 - 0
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md

@@ -0,0 +1,31 @@
+# Agent Note:投影缓存改为每会话文件
+
+Status: implemented
+
+[English](2026-08-19-projection-cache-per-session-files.md) | 中文
+
+## Problem
+
+持久投影缓存曾是单个全局 `session_projcache.json`——存储根目录下一个文件里的 `sessions` 表。每次节流检查点都会重写包含所有会话行的整个文件,写放大随会话数量增长;且一个畸形文件会让整个缓存一起失效。
+
+## Decision
+
+缓存打开采用新增 `per-record` 布局的 `session_projcache` 存储域:每个会话一个带版本戳的文档,位于 `<root>/session_projcache/sessions/<id>.json`,介质归存储栈所有——`storage` / `storage-json` / `storage-domain` 与缓存一起落在共享 base 装配里,缓存重新变回纯粹的域消费方。所有随附且基于 base 的 profile 都保持启用缓存,因此会话生产方会记录检查点,不取决于当前应用是否提供列表接口;不使用 base 组合包的 `sdk-minimal` 不在此装配范围内。缓存绝不咨询持久化层:没有 `locate`、不依赖挂载的是哪个后端。
+
+读写共享同一份一致状态:每次读取(`cachedSnapshot`)都是对域内存表的同步查找(零 I/O);每次写入排进该域的单条写链,先落盘成功才改内存——不再有落后于节流写入的直读磁盘。缓存保留其余全部职责:检查点折叠、写策略(turn/end + dispose 强制点、count/interval 节流)、fail-soft 持久化与列表读。`cachedSnapshot(meta)` 是同步的。缓存不运行冷重折叠阶梯(那需要读取会话日志,属于持久化层的职责);需要保证冷快照的消费方自行从日志重折叠。json 后端以仅属主权限(`0o700`)创建自己的目录树。
+
+## Consequences
+
+- 每会话写入隔离:每次节流写入只替换该会话的小文档,消除全局写放大。域写链将写入串行化,新切面绝不会先于旧切面落盘;域关闭时会排空在途写入。
+- 列表读取是同步内存读;没有记录文档的会话只是缺少投影列。
+- ACP、headless、SDK 与 Web 会话都会发布缓存行,供后续消费方使用。确保日志领先的持久性屏障可能按缓存节奏 flush 已覆盖的前缀,并拆分原本会合并的物理 JSONL 行;各 profile 的录制快照会重新 pack 逻辑事件流,因此缓存时序不会决定 fixture 布局。
+- per-record 契约把故障范围缩小到单记录:畸形或过期版本的文档在打开时读作"无此记录",单个坏文件不会拖垮整个缓存;检查点 schema 升级按会话丢弃过期行,而不是拒绝整个域。
+- json 后端仅在枚举时没有发现任何新布局文档路径,才从旧整单元缓存引导 per-record 目录树。只要存在任意新文档路径,即使文件不可读或版本陈旧,也会对整个单元禁用引导;缺失的会话行从日志重折叠。旧文件保持不变。
+- 缓存记录仍绑定同一日志生命周期:存储的 `{createdAt, cwd}` 身份防止被重建的 id 误导。
+
+## Alternatives considered
+
+- **保留全局 sessions 表。** 保留一次加载式列表,但保留了促成此改动的全局写放大与单文件爆炸半径。
+- **缓存自持的每会话文件**(`<root>/<session-id>/projection_cache.json`,本改动的第一版)。试过并在评审中回退:缓存手搓了介质——路径、按路径的写链、在途跟踪、仅属主文件权限,以及 sqlite 无路径特判——而且它的列表读每次调用都直读磁盘、写却在节流,读写永不一致。
+- **经 `sessionPersistence.locate(meta)` 解析路径**(文件放在会话日志旁)。未采用:缓存得从日志 artifact 路径"猜"日志旁边(`dirname` + 固定文件名),把缓存耦合到持久化服务与后端的布局。
+- **把 `per-record` 做成既有单元的一种模式而非独立单元类。** 未采用:两种布局的状态模型本质不同——`single` 内存权威、整文件发布;`per-record` 无状态(目录即状态,`loadAll` 重扫目录树)——所以它们是同一后端下的两个小型独立类,记录键做路径安全校验而非编码。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md
-2026-07-31-resume-selector-batch-projection.md: 5b0b33c34137efb95f31e07e7fce7622aeb2dbce
-2026-07-31-resume-selector-batch-projection.zh.md: a8e89c2e989680a1d1bf98f0cd2817b5900d36d0
+2026-07-31-resume-selector-batch-projection.md: 387d05e055c2b90f3aa7ee39d624c117ba54b4b1
+2026-07-31-resume-selector-batch-projection.zh.md: febd744b3f7ec58dab94f5d8437feaa270dfffcf

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md

@@ -12,7 +12,7 @@ Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per
 
 Selector rows fold nothing but titles, and everything else a row shows comes from metadata:
 
-- Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, zero I/O), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the disabled "Unreadable session" fallback.
+- Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, one file read per session), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the disabled "Unreadable session" fallback.
 - The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp.
 - The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff.
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md

@@ -12,7 +12,7 @@ Status: implemented
 
 选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据:
 
-- 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,零 I/O),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描零 I/O。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的「Unreadable session」回退。
+- 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,每会话一次文件读取),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描每会话一次文件读取。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的「Unreadable session」回退。
 - 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。
 - 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。
 

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
-2026-07-28-storage-root-and-derived-medium-recovery.md: cfed831be7eb0fceb5ef7d9778803c602e809179
-2026-07-28-storage-root-and-derived-medium-recovery.zh.md: e535cf703e55b03ccd9a767e03aa73f621b17466
+2026-07-28-storage-root-and-derived-medium-recovery.md: 1505be1c58d5cf829327b2919113bb2e42798ce7
+2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 1bab5ab663df1419cc826c3d6acb59bd8bff7de0

+ 1 - 1
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md

@@ -10,7 +10,7 @@ The persisted projection cache ([note](2026-07-27-session-projection-and-command
 
 **Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `<launch dir>/.storages/` — two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session/session-persistence-jsonl/src/index.ts).
 
-**Recovery behavior.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
+**Recovery behavior.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which describes an aspiration, not the implementation. Partially superseded for the projection cache: [the per-session cache files note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.md) removed the global `session_projcache` domain, so the cache half of this proposal (recovery on that domain) no longer applies; the `workspace.json` half remains current. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
 
 ## Proposal
 

+ 1 - 1
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md

@@ -10,7 +10,7 @@ Status: proposed
 
 **文件到底存在哪(根错位已收口,resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 曾给 json 后端相对根 `./.storages`:`workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除:overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages`(`apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 仍从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts);出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它("later process.cwd() changes cannot split one backend across roots",packages/session/session-persistence-jsonl/src/index.ts)。
 
-**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
+**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者描述的是愿望而非实现。投影缓存半边已被[每会话缓存文件 note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md) 部分取代:全局 `session_projcache` domain 已移除,本提案的缓存恢复半边不再适用;`workspace.json` 半边仍然有效。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
 
 ## 提案
 

+ 12 - 0
apps/cli/composition.md

@@ -56,6 +56,14 @@ flowchart LR
   cfg --> plugin_dsh_base_session_query_sqlite
   plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
   cfg --> plugin_dsh_base_session_projection
+  plugin_dsh_base_storage["storage<br/>@deepseek-ai/dsh-storage"]
+  cfg --> plugin_dsh_base_storage
+  plugin_dsh_base_storage_json["storage-json<br/>@deepseek-ai/dsh-storage-json"]
+  cfg --> plugin_dsh_base_storage_json
+  plugin_dsh_base_storage_domain["storage-domain<br/>@deepseek-ai/dsh-storage-domain"]
+  cfg --> plugin_dsh_base_storage_domain
+  plugin_dsh_base_session_projection_cache["session-projection-cache<br/>@deepseek-ai/dsh-session-projection-cache"]
+  cfg --> plugin_dsh_base_session_projection_cache
   plugin_dsh_base_session_telemetry_otel["session-telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
   cfg --> plugin_dsh_base_session_telemetry_otel
   plugin_dsh_base_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
@@ -200,6 +208,10 @@ flowchart LR
 | `attachment-local` | `@deepseek-ai/dsh-attachment-local` |
 | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
 | `session-projection` | `@deepseek-ai/dsh-session-projection` |
+| `storage` | `@deepseek-ai/dsh-storage` |
+| `storage-json` | `@deepseek-ai/dsh-storage-json` |
+| `storage-domain` | `@deepseek-ai/dsh-storage-domain` |
+| `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
 | `session-telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
 | `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
 | `sandbox` | `@deepseek-ai/dsh-sandbox-local` |

+ 4 - 4
apps/web/tests/agent-preset-selection.e2e.ts

@@ -15,7 +15,7 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
+  SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId,
 } from '@deepseek-ai/dsh-session'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
 import {
@@ -87,7 +87,7 @@ function seedLog(): string {
 async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise<void> {
   const childId = sessionId('agent-preset-selection-child')
   const createdAt = 1784974100100
-  await scaffold.ctx.sessionPersistence.create({
+  const header: SessionHeader = {
     version: SESSION_FORMAT_VERSION,
     id: childId,
     createdAt,
@@ -96,7 +96,8 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
     origin: 'subagent',
     delegationDepth: 1,
     agentPreset: 'minimal',
-  })
+  }
+  await scaffold.ctx.sessionPersistence.create(header)
   await scaffold.ctx.sessionPersistence.append(childId, [
     {
       type: 'turn/start',
@@ -129,7 +130,6 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
       data: { turn: 1, reason: { kind: 'completed' } },
     },
   ] as SessionEvent[])
-  await scaffold.ctx.sessionProjectionCache.coldSnapshot(childId)
 }
 
 /**

+ 29 - 11
apps/web/tests/subagent-conversation.e2e.ts

@@ -6,7 +6,7 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionId,
+  SESSION_FORMAT_VERSION, SessionId as sessionId, type SessionEvent, type SessionHeader, type SessionId,
 } from '@deepseek-ai/dsh-session'
 import type {} from '@deepseek-ai/dsh-agent'
 import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
@@ -58,6 +58,18 @@ async function waitForAgentToSettle(scaffold: WebScaffold, id: SessionId): Promi
   }
 }
 
+/** Poll until the cold-read write-back of {@link coldSnapshot} lands a visible row. */
+async function waitForCacheRow(
+  scaffold: WebScaffold,
+  header: SessionHeader,
+): Promise<void> {
+  const deadline = Date.now() + 10_000
+  while (scaffold.ctx.sessionProjectionCache.cachedSnapshot(header) === undefined) {
+    if (Date.now() >= deadline) throw new Error(`cache row for "${header.id}" did not land`)
+    await new Promise<void>(resolve => setTimeout(resolve, 10))
+  }
+}
+
 describe('web e2e: persisted subagent conversation and human continuation', () => {
   let scaffold: WebScaffold
   let browser: Browser
@@ -114,7 +126,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     oneShotId = sessionId('recorded-one-shot')
     const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000
     const oneShotAt = Date.now() - oneShotDurationMs
-    await scaffold.ctx.sessionPersistence.create({
+    const oneShotHeader: SessionHeader = {
       version: SESSION_FORMAT_VERSION,
       id: oneShotId,
       createdAt: oneShotAt,
@@ -122,8 +134,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       parentSession: parent.id,
       origin: 'subagent',
       delegationDepth: 1,
-    })
-    await scaffold.ctx.sessionPersistence.append(oneShotId, [
+    }
+    await scaffold.ctx.sessionPersistence.create(oneShotHeader)
+    const oneShotEvents = [
       {
         type: 'turn/start',
         seq: 0,
@@ -154,11 +167,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
         time: oneShotAt + oneShotDurationMs,
         data: { turn: 1, reason: { kind: 'completed' } },
       },
-    ] as SessionEvent[])
-    await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId)
+    ] as SessionEvent[]
+    await scaffold.ctx.sessionPersistence.append(oneShotId, oneShotEvents)
+    scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotHeader, oneShotEvents)
+    await waitForCacheRow(scaffold, oneShotHeader)
     grandchildId = sessionId('recorded-grandchild')
     const authoredAt = Date.now()
-    await scaffold.ctx.sessionPersistence.create({
+    const grandchildHeader: SessionHeader = {
       version: SESSION_FORMAT_VERSION,
       id: grandchildId,
       createdAt: authoredAt,
@@ -166,8 +181,9 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       parentSession: childId,
       origin: 'subagent',
       delegationDepth: 2,
-    })
-    await scaffold.ctx.sessionPersistence.append(grandchildId, [
+    }
+    await scaffold.ctx.sessionPersistence.create(grandchildHeader)
+    const grandchildEvents = [
       {
         type: 'turn/start',
         seq: 0,
@@ -198,8 +214,10 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
         time: authoredAt + 3,
         data: { turn: 1, reason: { kind: 'completed' } },
       },
-    ] as SessionEvent[])
-    await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId)
+    ] as SessionEvent[]
+    await scaffold.ctx.sessionPersistence.append(grandchildId, grandchildEvents)
+    scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildHeader, grandchildEvents)
+    await waitForCacheRow(scaffold, grandchildHeader)
     expect(scaffold.ctx.agents.get(childId)).toBeUndefined()
     expect(scaffold.ctx.agents.get(oneShotId)).toBeUndefined()
     expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined()

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: ac22d6b660a975f1252c3ce852d932f9c6f7d3c4
-config-catalog.zh.md: 1357f546281c0ee78674cbb1773937f9bdeeeaee
+config-catalog.md: f63a09429e4a3543153ebafa156532263005aecc
+config-catalog.zh.md: f2b7edf637c968fbc92c10cfc05bdb1849136cbc

+ 7 - 6
docs/config-catalog.md

@@ -1822,14 +1822,15 @@ Source: [`packages/session/session-persistence-sqlite/src/index.ts:38`](../packa
 
 ## `@deepseek-ai/dsh-session-projection-cache`
 
-Requires: `storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions`
+Requires: `storageDomain` · `sessionProjections` · `sessions`
 
 ```ts config-catalog
 /**
  * Plugin config. Both throttle triggers are deployment choices with no
  * universally correct value, so the composition states them explicitly
- * (cordis.yml); the two mandatory write points (`turn/end` and session
- * disposal) are policy, not tunables, and always fire.
+ * (cordis.yml); the three mandatory write points (session creation,
+ * `turn/end`, and session disposal) are policy, not tunables, and always
+ * fire.
  */
 export interface Config {
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
@@ -1839,7 +1840,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 
@@ -2175,12 +2176,12 @@ Requires: `storage`
  * location explicitly.
  */
 export interface Config {
-  /** Directory holding one `<unit>.json` file per unit. */
+  /** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
   root: string
 }
 ```
 
-Source: [`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts)
+Source: [`packages/storage/storage-json/src/index.ts:28`](../packages/storage/storage-json/src/index.ts)
 
 <a id="deepseek-aidsh-storage-sqlite"></a>
 

+ 7 - 6
docs/config-catalog.zh.md

@@ -1824,14 +1824,15 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 
 ## `@deepseek-ai/dsh-session-projection-cache`
 
-需要:`storageDomain` · `sessionProjections` · `sessionPersistence` · `sessions`
+需要:`storageDomain` · `sessionProjections` · `sessions`
 
 ```ts config-catalog
 /**
  * Plugin config. Both throttle triggers are deployment choices with no
  * universally correct value, so the composition states them explicitly
- * (cordis.yml); the two mandatory write points (`turn/end` and session
- * disposal) are policy, not tunables, and always fire.
+ * (cordis.yml); the three mandatory write points (session creation,
+ * `turn/end`, and session disposal) are policy, not tunables, and always
+ * fire.
  */
 export interface Config {
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
@@ -1841,7 +1842,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/session/session-projection-cache/src/index.ts:46`](../packages/session/session-projection-cache/src/index.ts)
+来源:[`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 
@@ -2177,12 +2178,12 @@ export interface Config {
  * location explicitly.
  */
 export interface Config {
-  /** Directory holding one `<unit>.json` file per unit. */
+  /** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
   root: string
 }
 ```
 
-来源:[`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts)
+来源:[`packages/storage/storage-json/src/index.ts:28`](../packages/storage/storage-json/src/index.ts)
 
 <a id="deepseek-aidsh-storage-sqlite"></a>
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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: b38d919b0586a116e163d8be3eb2c33fcfc6d67d
-event-producer-consumer.zh.md: fd36b3ebf40d5467f4b0849d972a7925838dee65
+event-producer-consumer.md: c4610aa3e6ad59764e4d3f85db33c8e0f285ac6e
+event-producer-consumer.zh.md: d6f4903d93d3829ccf753ad98d9237094d726c3e

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

@@ -45,7 +45,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |

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

@@ -47,7 +47,7 @@
 | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` |
 | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
 | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) |

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: b407080d634c0e70a00f494c686f55f85998046e
-module-graph.zh.md: 542ea6be5a1f1f41c5b39e4e83b2c49c97439332
+module-graph.md: c0fcd9366e2b0c1c60a7672ecbb1f12fe3bac998
+module-graph.zh.md: d4c5600a67bdf29184b5376d7800fa1b329039c1

+ 1 - 2
docs/module-graph.md

@@ -586,7 +586,6 @@ flowchart TD
   pkg_session_persistence_sqlite --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_invariants
   pkg_session_projection_cache --> pkg_session
-  pkg_session_projection_cache --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_session_projection
   pkg_session_projection_cache --> pkg_storage_domain
   pkg_session_stats --> pkg_invariants
@@ -1749,7 +1748,7 @@ flowchart TD
 | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
+| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
 | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |

+ 1 - 2
docs/module-graph.zh.md

@@ -588,7 +588,6 @@ flowchart TD
   pkg_session_persistence_sqlite --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_invariants
   pkg_session_projection_cache --> pkg_session
-  pkg_session_projection_cache --> pkg_session_persistence
   pkg_session_projection_cache --> pkg_session_projection
   pkg_session_projection_cache --> pkg_storage_domain
   pkg_session_stats --> pkg_invariants
@@ -1751,7 +1750,7 @@ flowchart TD
 | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
 | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
 | [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
+| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
 | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
 | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
 | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |

+ 2 - 2
docs/subsystems/session-projection.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/session-projection.md
-session-projection.md: c66a1c23930d4855add50465414a6b0ac64baab7
-session-projection.zh.md: 56e08754e0504b032b5820d9d7165ac70b0bb501
+session-projection.md: 67e212e5c0379d2446fd0baed3f511c4ea2630f2
+session-projection.zh.md: 337cb08853998d325dd063b99b601ae99552285b

+ 21 - 20
docs/subsystems/session-projection.md

@@ -113,17 +113,17 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.sessionProjectionCache` — `SessionProjectionCache`
 
-The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.
+The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
 
 ```ts cordis-catalog
 /**
  * The zero-I/O listing read: whole values viewed straight from the stored
- * rows (version-matching keys only), each cut carried with its watermark
- * so a client value store can seed under its higher-seq-wins rule — as
- * stale as the last durable checkpoint but never wrong, and never from an
+ * rows (version-matching keys only), each cut carried with its watermark so
+ * a client value store can seed under its higher-seq-wins rule — as stale
+ * as the last durable checkpoint but never wrong, and never from an
  * unrelated log (the caller's header is the identity witness). Fresher
- * paths (the history tail baseline, {@link coldSnapshot}) supersede these
- * values whenever a session is actually opened.
+ * paths (the history tail baseline) supersede these values whenever a
+ * session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
  * @param keys - optional projection keys required by the caller's audience.
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
@@ -146,29 +146,30 @@ hydratePrepared( session: Session, meta: SessionHeader, events: readonly Session
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
  * this; tests and carriers may too). The registry cut is snapshotted at
- * this boundary (states are live references), then the whole record is
- * replaced. NOT fail-soft — callers on the fail-soft paths contain it.
+ * this boundary (states are live references), then the session's record is
+ * replaced on the domain's write chain. NOT fail-soft — callers on the
+ * fail-soft paths contain it.
  * @param session - the live session to checkpoint.
  * @returns resolution after durability and event emission.
  */
 async write(session: Session): Promise<void>
 
 /**
- * Cold-read one persisted session's projections with zero full-log load:
- * cached rows + a persistence `readFrom` tail from the registry's restore
- * floor, refolded by the registry and written back (fail-soft) so the next
- * cold read starts closer. A cache row invalidated by a shrunk log
- * (crash-repair truncation) triggers one full re-read from seq 0 — the
- * ladder's slow rung, still no crash. Rejects when the session has no
- * persisted log (`not found` from the persistence seam).
- * @param id - the persisted session to read.
- * @param signal - optional cancellation for the persistence reads.
- * @returns the snapshot cut at the stored log end.
+ * Cold-read one session's projections from its complete log. Each unit is
+ * seeded from the identity-checked cached rows — the registry skips `apply`
+ * for the already-folded prefix (events at or below the row's `seq`) — and
+ * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
+ * the first cold read creates the cache row and later ones seed from it.
+ * The caller supplies the complete log in seq order: this service never
+ * consults the persistence layer.
+ * @param meta - the stored session header (identity witness).
+ * @param events - the session's complete log, in seq order.
+ * @returns the projection cut at the log end.
  */
-async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
+coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot
 ```
 
-Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md) · [SessionId](core.md)
+Types: [Session](session.md) · [SessionEvent](session.md) · [SessionHeader](persistence.md)
 
 Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts)
 

+ 21 - 20
docs/subsystems/session-projection.zh.md

@@ -113,17 +113,17 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.sessionProjectionCache` — `SessionProjectionCache`
 
-The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.
+The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
 
 ```ts cordis-catalog
 /**
  * The zero-I/O listing read: whole values viewed straight from the stored
- * rows (version-matching keys only), each cut carried with its watermark
- * so a client value store can seed under its higher-seq-wins rule — as
- * stale as the last durable checkpoint but never wrong, and never from an
+ * rows (version-matching keys only), each cut carried with its watermark so
+ * a client value store can seed under its higher-seq-wins rule — as stale
+ * as the last durable checkpoint but never wrong, and never from an
  * unrelated log (the caller's header is the identity witness). Fresher
- * paths (the history tail baseline, {@link coldSnapshot}) supersede these
- * values whenever a session is actually opened.
+ * paths (the history tail baseline) supersede these values whenever a
+ * session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
  * @param keys - optional projection keys required by the caller's audience.
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
@@ -146,29 +146,30 @@ hydratePrepared( session: Session, meta: SessionHeader, events: readonly Session
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
  * this; tests and carriers may too). The registry cut is snapshotted at
- * this boundary (states are live references), then the whole record is
- * replaced. NOT fail-soft — callers on the fail-soft paths contain it.
+ * this boundary (states are live references), then the session's record is
+ * replaced on the domain's write chain. NOT fail-soft — callers on the
+ * fail-soft paths contain it.
  * @param session - the live session to checkpoint.
  * @returns resolution after durability and event emission.
  */
 async write(session: Session): Promise<void>
 
 /**
- * Cold-read one persisted session's projections with zero full-log load:
- * cached rows + a persistence `readFrom` tail from the registry's restore
- * floor, refolded by the registry and written back (fail-soft) so the next
- * cold read starts closer. A cache row invalidated by a shrunk log
- * (crash-repair truncation) triggers one full re-read from seq 0 — the
- * ladder's slow rung, still no crash. Rejects when the session has no
- * persisted log (`not found` from the persistence seam).
- * @param id - the persisted session to read.
- * @param signal - optional cancellation for the persistence reads.
- * @returns the snapshot cut at the stored log end.
+ * Cold-read one session's projections from its complete log. Each unit is
+ * seeded from the identity-checked cached rows — the registry skips `apply`
+ * for the already-folded prefix (events at or below the row's `seq`) — and
+ * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
+ * the first cold read creates the cache row and later ones seed from it.
+ * The caller supplies the complete log in seq order: this service never
+ * consults the persistence layer.
+ * @param meta - the stored session header (identity witness).
+ * @param events - the session's complete log, in seq order.
+ * @returns the projection cut at the log end.
  */
-async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
+coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot
 ```
 
-Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md)
+Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionHeader](persistence.zh.md)
 
 Source: [`packages/session/session-projection-cache/src/index.ts`](../../packages/session/session-projection-cache/src/index.ts)
 

+ 2 - 2
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: b850b0229ae8d1fc4fc5352000c8a4a8c06b3ade
-storage.zh.md: 0cadaa3ea302b2524c991df3e2f31bdbe0a93dee
+storage.md: 1e4141e6ef1c6f8e1c2593e21e788b626d6b1ed7
+storage.zh.md: f0433c600674741c3de0ce3e99430297839ce124

+ 8 - 0
docs/subsystems/storage.md

@@ -57,6 +57,14 @@ interface DomainSpec {
   readonly name: string
   /** Domain format version; a medium stamped with a different version rejects at open. */
   readonly version: number
+  /**
+   * Medium layout for the backend unit: `single` (the default) stores the
+   * whole unit as one document; `per-record` stores each record as its own
+   * document, for units whose records are large, sparse, or individually
+   * disposable — the projection cache — and scopes version bumps per record
+   * (a stale record document is discarded, never migrated).
+   */
+  readonly layout?: 'single' | 'per-record'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */

+ 8 - 0
docs/subsystems/storage.zh.md

@@ -57,6 +57,14 @@ interface DomainSpec {
   readonly name: string
   /** Domain format version; a medium stamped with a different version rejects at open. */
   readonly version: number
+  /**
+   * Medium layout for the backend unit: `single` (the default) stores the
+   * whole unit as one document; `per-record` stores each record as its own
+   * document, for units whose records are large, sparse, or individually
+   * disposable — the projection cache — and scopes version bumps per record
+   * (a stale record document is discarded, never migrated).
+   */
+  readonly layout?: 'single' | 'per-record'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */

+ 2 - 2
packages/bundle/acp-app/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/bundle/acp-app/README.md
-README.md: d00458d3e23cfd9ff8984454aace991d3c2f8dd9
-README.zh.md: e32eac45d407af25c474b6df483dc587dd4a5038
+README.md: 5cb1e33319c5da04de19d3c8eb1c24cb72ec2312
+README.zh.md: 13debf94c4fe209a74f0174fda7195c383bf590a

+ 1 - 1
packages/bundle/acp-app/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The automation-only ACP stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona and default model route, mounts an app-owned zero-option command provider, and starts [`dsh-acp`](../../acp/acp/README.md) only after that provider accepts the invocation. `dsh --profile acp --help` therefore writes help and exits without claiming stdin or stdout.
 
-The startup provider binds stdin EOF to the launcher's bounded successful shutdown. ACP connection close, SIGINT, and SIGTERM drain the bridge-owned agents and the root profile tree before exit. Stdout is reserved for newline-delimited ACP JSON-RPC frames. The bundle disables model-generated session titles because ACP exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
+The startup provider binds stdin EOF to the launcher's bounded successful shutdown. ACP connection close, SIGINT, and SIGTERM drain the bridge-owned agents and the root profile tree before exit. Stdout is reserved for newline-delimited ACP JSON-RPC frames. The bundle disables model-generated session titles because ACP exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints ACP-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
 
 The shipped row creates sessions with `deepseek-official` and `deepseek-v4-flash`; a later patch can replace that row's complete config. The base profile owns adapters, tools, persistence, policy, settings, credentials, and the per-session workspace supplied by the ACP client.
 

+ 1 - 1
packages/bundle/acp-app/README.zh.md

@@ -4,7 +4,7 @@
 
 以 [`dsh-base`](../base/README.zh.md) 为基础的 automation-only ACP stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona 与默认模型路由、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-acp`](../../acp/acp/README.zh.md)。因此,`dsh --profile acp --help` 会写出 help 并退出,不会占用 stdin 或 stdout。
 
-启动提供方把 stdin EOF 绑定到启动器的有界成功关闭。ACP 连接关闭、SIGINT 与 SIGTERM 会在退出前排空 bridge 自有 agent 以及根 profile 树。Stdout 仅保留给换行分隔的 ACP JSON-RPC frame。ACP 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。部署方通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个 app bin。
+启动提供方把 stdin EOF 绑定到启动器的有界成功关闭。ACP 连接关闭、SIGINT 与 SIGTERM 会在退出前排空 bridge 自有 agent 以及根 profile 树。Stdout 仅保留给换行分隔的 ACP JSON-RPC frame。ACP 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 ACP 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署方通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个 app bin。
 
 随附配置项使用 `deepseek-official` 与 `deepseek-v4-flash` 创建 session;后续 patch 可以替换该配置项的完整 config。base profile 负责适配器、工具、持久化、策略、settings 与 credentials;ACP client 为每个 session 提供工作区。
 

+ 27 - 0
packages/bundle/base/cordis.patch.yml

@@ -138,6 +138,33 @@
     - id: session-projection
       name: '@deepseek-ai/dsh-session-projection'
 
+    # Durable KV storage: the storage hub, the json backend, and the
+    # schema-validated domain form over them. Session-layer persistence (the
+    # projection cache below; workspace and message-feedback in web layers)
+    # routes through this stack, so it belongs to the shared base.
+    - id: storage
+      name: '@deepseek-ai/dsh-storage'
+
+    - id: storage-json
+      name: '@deepseek-ai/dsh-storage-json'
+      config:
+        root: !!js dshHomePath('storages')
+
+    - id: storage-domain
+      name: '@deepseek-ai/dsh-storage-domain'
+      config:
+        backend: json
+
+    # Persisted projection cache: throttled write-behind over the
+    # session_projcache domain (per-record layout — one version-stamped
+    # checkpoint document per session), serving the session listing's
+    # projection column.
+    - id: session-projection-cache
+      name: '@deepseek-ai/dsh-session-projection-cache'
+      config:
+        writeEveryEvents: 200
+        writeIntervalMs: 5000
+
     # Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE
     # explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors
     # session-log records onto OTLP/HTTP logs with no session-telemetry/record redaction

+ 4 - 0
packages/bundle/base/package.json

@@ -77,6 +77,7 @@
     "@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
     "@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
     "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
@@ -87,6 +88,9 @@
     "@deepseek-ai/dsh-skill-filesystem": "workspace:^",
     "@deepseek-ai/dsh-spill-local": "workspace:^",
     "@deepseek-ai/dsh-spill-policy": "workspace:^",
+    "@deepseek-ai/dsh-storage": "workspace:^",
+    "@deepseek-ai/dsh-storage-domain": "workspace:^",
+    "@deepseek-ai/dsh-storage-json": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
     "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",

+ 2 - 2
packages/bundle/headless/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/bundle/headless/README.md
-README.md: 373e50c515ef45d09c32e7dd1b011f149d921250
-README.zh.md: 443945ced7e5e41899e91e8c169918f4e186de67
+README.md: 516b21ca4b31fcd8c69b7bdb1f0d5fe60d5e43e2
+README.zh.md: 37781a79629dd1a183045ef533eb9849b2b0219a

+ 1 - 1
packages/bundle/headless/README.md

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it inherits the base's disabled module-HMR policy, supplies the coding persona and tool mode, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin.
+The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it inherits the base's disabled module-HMR policy and projection cache, supplies the coding persona and tool mode, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs.
 
 After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. Each non-empty provider reasoning delta from that Agent is written to stderr as it arrives under a `dsh: reasoning:` heading; consecutive deltas remain one section, and the runner terminates the section before later output when the provider supplied no trailing newline. It then flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; a successful run with no reasoning keeps stderr empty. The process opens no listening port.
 

+ 1 - 1
packages/bundle/headless/README.zh.md

@@ -2,7 +2,7 @@
 
 [English](README.md) | 中文
 
-dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.zh.md) 之上:继承 base 默认禁用模块 HMR(热模块替换)的策略,提供编码 persona 和工具模式,将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。
+dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.zh.md) 之上:继承 base 默认禁用模块 HMR(热模块替换)的策略与投影缓存,提供编码 persona 和工具模式,将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。缓存会为每个持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。
 
 Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.zh.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。该 Agent 每次产生非空的提供方推理分片时,runner 都会在 `dsh: reasoning:` 标题下将其即时写入 stderr;连续分片保留在同一段中,提供方没有输出末尾换行时,runner 会在后续输出前终止该段。随后,它对 Session 执行 flush,再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,并经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.zh.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终结束原因为 `error` 时,还会将 code 与 message 写入 stderr;没有推理内容的成功运行会保持 stderr 为空。进程不会打开监听端口。
 

+ 2 - 2
packages/bundle/sdk-app/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/bundle/sdk-app/README.md
-README.md: c5022bd2096fae931fff48944bfc167280e62476
-README.zh.md: 687a19dc09679d11696a207ad17db3cf463ee901
+README.md: 5c65c4a318683d51e930473a9715d290e331e60a
+README.zh.md: 935e2d88ce7730c6f6cef39dc9ff3d1ddb644bb4

+ 1 - 1
packages/bundle/sdk-app/README.md

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
 
 The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider and supplies its own profile name.
 
-The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
+The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints SDK-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin.
 
 | Config | Default | Behavior |
 |---|---|---|

+ 1 - 1
packages/bundle/sdk-app/README.zh.md

@@ -4,7 +4,7 @@
 
 以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) 组合包复用同一个启动提供方,并提供自己的 profile 名称。
 
-启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。
+启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 SDK 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。
 
 | 配置 | 默认值 | 行为 |
 |---|---|---|

+ 2 - 2
packages/bundle/web-app/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/bundle/web-app/README.md
-README.md: c330e5557c2fa70f60ededcd368ec713db11269e
-README.zh.md: b2170c762a2e9425417df09e7ba6e7c16f612e96
+README.md: dd3b5787000be18920e6b773f27ebe65abe4d20a
+README.zh.md: 6e9e99b8485ebb527e69c38e28abf9c9eef44303

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
packages/bundle/web-app/README.md


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
packages/bundle/web-app/README.zh.md


+ 0 - 19
packages/bundle/web-app/cordis.patch.yml

@@ -49,19 +49,6 @@
     - id: code-runtime
       name: '@deepseek-ai/dsh-code-runtime-worker-thread'
 
-    - id: storage
-      name: '@deepseek-ai/dsh-storage'
-
-    - id: storage-json
-      name: '@deepseek-ai/dsh-storage-json'
-      config:
-        root: !!js dshHomePath('storages')
-
-    - id: storage-domain
-      name: '@deepseek-ai/dsh-storage-domain'
-      config:
-        backend: json
-
     - id: message-feedback
       name: '@deepseek-ai/dsh-message-feedback'
       config:
@@ -74,12 +61,6 @@
     - id: workspace
       name: '@deepseek-ai/dsh-workspace'
 
-    - id: session-projection-cache
-      name: '@deepseek-ai/dsh-session-projection-cache'
-      config:
-        writeEveryEvents: 200
-        writeIntervalMs: 5000
-
     - id: session-reference
       name: '@deepseek-ai/dsh-session-reference'
 

+ 0 - 4
packages/bundle/web-app/package.json

@@ -104,15 +104,11 @@
     "@deepseek-ai/dsh-file-reference-local": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-message-feedback": "workspace:^",
-    "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
     "@deepseek-ai/dsh-session-reference": "workspace:^",
     "@deepseek-ai/dsh-session-log-export": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
-    "@deepseek-ai/dsh-storage": "workspace:^",
-    "@deepseek-ai/dsh-storage-domain": "workspace:^",
-    "@deepseek-ai/dsh-storage-json": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^",

+ 3 - 3
packages/credentials/credentials-local/tests/migration.spec.ts

@@ -4,16 +4,16 @@
 // keeps the loud rejection local.spec exercises.
 import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
-import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
+import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { LocalCredentialProvider, renderFlatLayoutMigration } from '../src/index.ts'
 
 /** Credential documents are seeded owner-only, exactly as the provider creates them. */
 function writeCredentials(file: string, text: string): Promise<void> {
-  return writeFile(file, text, { mode: 0o600 })
+  return writeFileAtomic(file, text, { mode: 0o600, dirMode: 0o700 })
 }
 
 const cleanups: Array<() => Promise<void>> = []

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

@@ -1396,11 +1396,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'sessionProjectionCache',
     summary: 'The persisted projection cache service.',
-    description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.',
+    description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus three mandatory points — session creation, `turn/end`, and session disposal (the live-to-cold moment) — and serves the cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.',
     methods: [
       {
         signature: 'cachedSnapshot( meta: SessionHeader, keys?: readonly Extract<keyof SessionProjectionMap, string>[], ): ProjectionSnapshot | undefined',
-        description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline, coldSnapshot) supersede these values whenever a session is actually opened.',
+        description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline) supersede these values whenever a session is actually opened.',
         parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'keys', description: 'optional projection keys required by the caller\'s audience.' }],
         returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.',
       },
@@ -1412,15 +1412,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'async write(session: Session): Promise<void>',
-        description: 'Durably checkpoint one live session NOW (both mandatory points call this; tests and carriers may too). The registry cut is snapshotted at this boundary (states are live references), then the whole record is replaced. NOT fail-soft — callers on the fail-soft paths contain it.',
+        description: 'Durably checkpoint one live session NOW (both mandatory points call this; tests and carriers may too). The registry cut is snapshotted at this boundary (states are live references), then the session\'s record is replaced on the domain\'s write chain. NOT fail-soft — callers on the fail-soft paths contain it.',
         parameters: [{ name: 'session', description: 'the live session to checkpoint.' }],
         returns: 'resolution after durability and event emission.',
       },
       {
-        signature: 'async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>',
-        description: 'Cold-read one persisted session\'s projections with zero full-log load: cached rows + a persistence `readFrom` tail from the registry\'s restore floor, refolded by the registry and written back (fail-soft) so the next cold read starts closer. A cache row invalidated by a shrunk log (crash-repair truncation) triggers one full re-read from seq 0 — the ladder\'s slow rung, still no crash. Rejects when the session has no persisted log (`not found` from the persistence seam).',
-        parameters: [{ name: 'id', description: 'the persisted session to read.' }, { name: 'signal', description: 'optional cancellation for the persistence reads.' }],
-        returns: 'the snapshot cut at the stored log end.',
+        signature: 'coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot',
+        description: 'Cold-read one session\'s projections from its complete log. Each unit is seeded from the identity-checked cached rows — the registry skips `apply` for the already-folded prefix (events at or below the row\'s `seq`) — and the refreshed checkpoint is written back (fail-soft, fire-and-forget), so the first cold read creates the cache row and later ones seed from it. The caller supplies the complete log in seq order: this service never consults the persistence layer.',
+        parameters: [{ name: 'meta', description: 'the stored session header (identity witness).' }, { name: 'events', description: 'the session\'s complete log, in seq order.' }],
+        returns: 'the projection cut at the log end.',
       },
     ],
   },
@@ -3633,7 +3633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'DomainSpec',
-    declaration: 'export interface DomainSpec {\n    readonly name: string;\n    readonly version: number;\n    readonly global?: DomainGlobalSpec<unknown>;\n    readonly tables: Record<string, DomainTableSpec>;\n}',
+    declaration: 'export interface DomainSpec {\n    readonly name: string;\n    readonly version: number;\n    readonly layout?: \'single\' | \'per-record\';\n    readonly global?: DomainGlobalSpec<unknown>;\n    readonly tables: Record<string, DomainTableSpec>;\n}',
   },
   {
     name: 'DomainTableSpec',
@@ -3921,7 +3921,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'KvUnitDescriptor',
-    declaration: 'export interface KvUnitDescriptor {\n    readonly name: string;\n    readonly version: number;\n    readonly tables: readonly string[];\n    readonly hasGlobal: boolean;\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}',
   },
   {
     name: 'LlmAdapter',

+ 2 - 2
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: 33908578a5127f2b6bb78ed7467833aaaa2cf085
-README.zh.md: 0ca410f91562360d85faadf4cf64cb61ac467482
+README.md: 3985daeca4dd3cc0b3a9b0fcf68070f368b0c31f
+README.zh.md: 19c494350b0f5a1dc5b7315f18ac5bf76eaee69e

+ 18 - 17
packages/session/session-projection-cache/README.md

@@ -2,42 +2,43 @@
 
 English | [中文](README.zh.md)
 
-The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
+The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one version-stamped document per session on the `session_projcache` storage domain in `per-record` layout (the shipped json backend lands each session's record at `<root>/session_projcache/sessions/<id>.json`). The cache never consults the persistence layer. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
 
 A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to:
 
-- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value.
+- **Reads and writes share one coherent state.** Every read is a synchronous lookup in the domain's in-memory tables; every write queues on the domain's per-unit write chain and mutates memory only after durability, so a read can never go around the write chain to the medium (no direct disk reads, no torn values).
+- **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write self-heals. A crash between writes costs a longer tail replay, never a wrong value.
 - **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
-- **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the zero-I/O view and rejected by restore so the cold-read ladder refolds it from the log.
-- **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud.
-- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
+- **A row must pass the live unit's `stateSchema`.** A malformed or stale record document reads as "no cache row" at open, so the cold path refolds from the log.
+- **Whole-record writes.** Each write atomically replaces the session's record document (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. The domain write chain serializes writes, so a newer cut never lands before an older one.
+- **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live header is the witness) before accepting a record, so a deleted-then-recreated id cannot let an old record seed state folded from an unrelated log.
 - **The log leads, 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 (a longer tail replay) but never ahead of it.
+- **The medium is the domain's, private by default.** The json backend creates its tree owner-only (`0o700`); the cache does not depend on which persistence backend is mounted — no `locate`, no per-session-dir probing.
 
 ## Write policy
 
-Two mandatory points, throttled in between:
+Three mandatory points, throttled in between:
 
 | Trigger | Nature |
 |---|---|
-| `turn/end` | Mandatory — the turn-final value is what cold reads want. |
-| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cold ladder serves this session. |
+| Session creation | Mandatory — the seed-derived cut (a forked child's inherited title, say) lands immediately, so a crash or a live-held session never loses it from the cold list. |
+| `turn/end` | Mandatory — the turn-final value is what listing reads want. |
+| Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cache serves this session's final cut. |
 | `writeEveryEvents` committed events | Config throttle (count). |
 | `writeIntervalMs` since the first dirty event | Config throttle (interval). |
 
-Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml.
+Both throttle triggers are required `Config` fields (no defaults): the flush cadence is a deployment choice stated in cordis.yml.
 
 ## Listing read (`cachedSnapshot(meta)`)
 
-The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column.
-
-## Cold read (`coldSnapshot(id, signal?)`)
-
-The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`.
+A synchronous, zero-I/O read from the domain's in-memory tables: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, absent or foreign record document, or no usable rows); the api-proxy list carrier turns that into an absent column.
 
 `write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment).
 
 ## Composition
 
+The cache opens its domain through the storage stack, so base mounts `storage`, `storage-json` (root `dshHomePath('storages')`), and `storage-domain` (`backend: json`) before it:
+
 ```yaml
 - id: session-projection-cache
   name: '@deepseek-ai/dsh-session-projection-cache'
@@ -46,11 +47,11 @@ The read ladder, zero full-log load on the happy path: cached rows → `sessionP
     writeIntervalMs: 5000
 ```
 
-Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
+Injects `storageDomain`, `sessionProjections`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
 
 ## Model Experience
 
-None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
+None, as the cache only persists host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
 
 #### KV Cache effect
 
@@ -60,4 +61,4 @@ None; the cache never assembles or sends provider requests.
 
 - **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.
-- **`coldSnapshot` reads are not deduplicated** — two concurrent cold reads of one session each run the ladder; last write-back wins (rows are equivalent), acceptable for listing-scale call rates.
+- **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.

+ 18 - 17
packages/session/session-projection-cache/README.zh.md

@@ -2,42 +2,43 @@
 
 [English](README.md) | 中文
 
-持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)(persisted projection cache 一节)。
+持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,在 `per-record` 布局的 `session_projcache` 存储域上为每个会话存一份带版本戳的文档(内置 json 后端把每个会话的记录落在 `<root>/session_projcache/sessions/<id>.json`)。缓存绝不咨询持久化层。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)(persisted projection cache 一节)。
 
 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
 
-- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
+- **读写共享同一份一致状态。** 每次读取都是对域内存表的同步查找;每次写入排进该域的单条写链,先落盘成功才改内存——读永远不会绕过写链去碰介质(没有直读磁盘、没有撕裂的值)。
+- **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
 - **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
-- **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。
-- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。
-- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
+- **存储行必须通过当前单元的 `stateSchema`。** 畸形或过期的记录文档在打开时读作"无缓存行",冷路径从日志重新折叠。
+- **整记录写入。** 每次写入原子替换该会话的记录文档(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。域写链将写入串行化,新切面绝不会先于旧切面落盘。
+- **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 为证验证它,再接受任何记录——被删后重建的 id 无法让旧记录播种来自无关日志的状态。
 - **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。
+- **介质归域所有,默认私有。** json 后端以仅属主权限(`0o700`)创建自己的目录树;缓存不依赖挂载的是哪个持久化后端——没有 `locate`、没有每会话目录探测。
 
 ## 写策略
 
-两个必写点,其间节流:
+三个必写点,其间节流:
 
 | 触发 | 性质 |
 |---|---|
-| `turn/end` | 必写——冷读要的正是轮次终值。 |
-| 会话释放(detach) | 必写——live 转 cold 的时刻;此后冷读阶梯接管该会话。 |
+| 会话创建 | 必写——seed 派生的切面(比如 fork 子会话继承的标题)立即落盘,崩溃或长期存活的会话也不会从冷列表丢失。 |
+| `turn/end` | 必写——列表读要的正是轮次终值。 |
+| 会话释放(detach) | 必写——live 转 cold 的时刻;此后缓存服务该会话的最终切面。 |
 | 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 |
 | 距首个脏事件 `writeIntervalMs` 毫秒 | 配置节流(间隔)。 |
 
-两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。
+两个节流 `Config` 字段均必填(无默认值):写入节奏是部署选择,由 cordis.yml 明示。
 
 ## 列表读(`cachedSnapshot(meta)`)
 
-零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
-
-## 冷读(`coldSnapshot(id, signal?)`)
-
-读取阶梯,正常路径无需加载全量日志:缓存行 → `sessionProjections.restoreFloor`(锚定在最低可用水位之前一个事件的位置)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。
+对域内存表的同步、零 I/O 读取:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、记录文档缺失或外来、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
 
 `write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。
 
 ## 组合
 
+缓存通过存储栈打开自己的域,因此 base 先挂 `storage`、`storage-json`(根 `dshHomePath('storages')`)与 `storage-domain`(`backend: json`):
+
 ```yaml
 - id: session-projection-cache
   name: '@deepseek-ai/dsh-session-projection-cache'
@@ -46,11 +47,11 @@
     writeIntervalMs: 5000
 ```
 
-注入 `storageDomain`、`sessionProjections`、`sessionPersistence`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。
+注入 `storageDomain`、`sessionProjections`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。
 
 ## 模型体验
 
-无,因为缓存只持久化并恢复 host 侧的、由已写入日志的会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
+无,因为缓存只持久化 host 侧的、由已写入日志的会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
 
 #### KV Cache 影响
 
@@ -60,4 +61,4 @@
 
 - **不提供淘汰或保留接口**:记录会按会话持续累积;清理已存储的检查点属于带外维护,与会话持久化采用相同策略。
 - **间隔节流采用按会话的粗粒度控制**:一次无脏数据的写入完成后,计时器会在首个脏事件到达时启动;对于持续但未达到条数阈值的事件流,系统每个间隔写入一次,而不采用滑动窗口。
-- **`coldSnapshot` 读取不去重**——同一会话的两个并发冷读各跑一遍阶梯;写回最后者胜(行等价),对列表级调用频率可接受。
+- **缓存侧不做冷重折叠**——缓存只服务并刷新自己的行,从不读取会话日志(不依赖持久化层);需要保证冷快照的消费方自行从日志重折叠。

+ 5 - 6
packages/session/session-projection-cache/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-session-projection-cache",
-  "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)",
+  "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read",
   "version": "0.1.1-rc.2",
   "publishConfig": {
     "access": "public"
@@ -36,20 +36,19 @@
     "zod": "^4.4.3"
   },
   "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
-    "@deepseek-ai/dsh-storage-domain": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/dsh-storage-domain": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-storage": "workspace:^",
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^"
+    "@deepseek-ai/dsh-storage-json": "workspace:^"
   }
 }

+ 65 - 77
packages/session/session-projection-cache/src/index.ts

@@ -1,13 +1,17 @@
 /**
  * Persisted projection cache (`ctx.sessionProjectionCache`): durable
- * checkpoints of every client-visible or explicitly persisted projection unit's state, one record per
- * session on the domain data form (`session_projcache` domain — the shipped
- * json backend lands it beside `workspace.json`). The cache is a fold
- * shortcut, never an authority: a row is possibly stale (its `seq`
- * says how stale) but never wrong, so every write path is fail-soft (a lost
- * write costs a longer tail replay on the next cold read) and a
- * `ver` mismatch discards the row instead of migrating it. Design
- * authority: the session-projection RFC
+ * checkpoints of every projection unit's state, one record per session on
+ * the `session_projcache` domain (`per-record` layout — the shipped json
+ * backend stores one document per session under its root). Reads and writes
+ * share ONE coherent state: the domain's in-memory tables serve every read
+ * synchronously, and each write lands on the domain's write chain (durability
+ * first, then memory), so a read can never observe a disk write the memory
+ * has not applied, or a memory value the disk does not hold. The cache is a
+ * fold shortcut, never an authority: a row
+ * is possibly stale (its `seq` says how stale) but never wrong, so every
+ * write path is fail-soft (a lost write costs a longer tail replay on the
+ * next cold read) and a `ver` mismatch discards the row instead of migrating
+ * it. Design authority: the session-projection RFC
  * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
  * @module @deepseek-ai/dsh-session-projection-cache
  */
@@ -16,9 +20,6 @@ import { Context, Service } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
-// Empty type import: applies the package's cordis Context merge
-// (`ctx.sessionPersistence`), which this service reads on the cold path.
-import type {} from '@deepseek-ai/dsh-session-persistence'
 import type {
   ProjectionCheckpoint,
   ProjectionSnapshot,
@@ -40,8 +41,9 @@ declare module '@deepseek-ai/cordis' {
 /**
  * Plugin config. Both throttle triggers are deployment choices with no
  * universally correct value, so the composition states them explicitly
- * (cordis.yml); the two mandatory write points (`turn/end` and session
- * disposal) are policy, not tunables, and always fire.
+ * (cordis.yml); the three mandatory write points (session creation,
+ * `turn/end`, and session disposal) are policy, not tunables, and always
+ * fire.
  */
 export interface Config {
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
@@ -66,14 +68,14 @@ interface DirtyState {
 /**
  * The persisted projection cache service. Opens the `session_projcache`
  * domain at init, checkpoints live sessions on a throttled write-behind
- * (count/interval triggers from {@link Config}) plus two mandatory points —
- * `turn/end` and session disposal (the live-to-cold moment) — and serves the
- * cold-read ladder: cached row, persistence `readFrom` tail, registry
- * `restore`, durable write-back. Every durable write is fail-soft: failures
- * log a warning and the cache self-heals on the next write or cold read.
+ * (count/interval triggers from {@link Config}) plus three mandatory points —
+ * session creation, `turn/end`, and session disposal (the live-to-cold
+ * moment) — and serves the
+ * cached rows for a session header. Every durable write is fail-soft:
+ * failures log a warning and the cache self-heals on the next write.
  */
 export class SessionProjectionCache extends Service {
-  static inject = ['storageDomain', 'sessionProjections', 'sessionPersistence', 'sessions']
+  static inject = ['storageDomain', 'sessionProjections', 'sessions']
 
   static Config: z<Config> = Config
 
@@ -97,7 +99,9 @@ export class SessionProjectionCache extends Service {
    * identity matches `expected`. A session id names a slot, not a lifecycle:
    * a recreated id or a persistence store swapped under a surviving cache
    * must not let an old record seed state folded from an unrelated log.
-   * Synchronous from the domain's in-memory state.
+   * Synchronous from the domain's in-memory state — the same state every
+   * write mutated, so a read can never go around the write chain to the
+   * medium.
    * @param id - the session whose record is read.
    * @param expected - the log identity the caller holds (live or stored header).
    * @returns the identity-matching record, or `undefined` (absent or unrelated).
@@ -110,12 +114,12 @@ export class SessionProjectionCache extends Service {
 
   /**
    * The zero-I/O listing read: whole values viewed straight from the stored
-   * rows (version-matching keys only), each cut carried with its watermark
-   * so a client value store can seed under its higher-seq-wins rule — as
-   * stale as the last durable checkpoint but never wrong, and never from an
+   * rows (version-matching keys only), each cut carried with its watermark so
+   * a client value store can seed under its higher-seq-wins rule — as stale
+   * as the last durable checkpoint but never wrong, and never from an
    * unrelated log (the caller's header is the identity witness). Fresher
-   * paths (the history tail baseline, {@link coldSnapshot}) supersede these
-   * values whenever a session is actually opened.
+   * paths (the history tail baseline) supersede these values whenever a
+   * session is actually opened.
    * @param meta - the listed session's header (identity witness; no log read).
    * @param keys - optional projection keys required by the caller's audience.
    * @returns the cut (`asOfSeq` = lowest served-row watermark), or
@@ -168,8 +172,9 @@ export class SessionProjectionCache extends Service {
   /**
    * Durably checkpoint one live session NOW (both mandatory points call
    * this; tests and carriers may too). The registry cut is snapshotted at
-   * this boundary (states are live references), then the whole record is
-   * replaced. NOT fail-soft — callers on the fail-soft paths contain it.
+   * this boundary (states are live references), then the session's record is
+   * replaced on the domain's write chain. NOT fail-soft — callers on the
+   * fail-soft paths contain it.
    * @param session - the live session to checkpoint.
    * @returns resolution after durability and event emission.
    */
@@ -188,49 +193,28 @@ export class SessionProjectionCache extends Service {
   }
 
   /**
-   * Cold-read one persisted session's projections with zero full-log load:
-   * cached rows + a persistence `readFrom` tail from the registry's restore
-   * floor, refolded by the registry and written back (fail-soft) so the next
-   * cold read starts closer. A cache row invalidated by a shrunk log
-   * (crash-repair truncation) triggers one full re-read from seq 0 — the
-   * ladder's slow rung, still no crash. Rejects when the session has no
-   * persisted log (`not found` from the persistence seam).
-   * @param id - the persisted session to read.
-   * @param signal - optional cancellation for the persistence reads.
-   * @returns the snapshot cut at the stored log end.
+   * Cold-read one session's projections from its complete log. Each unit is
+   * seeded from the identity-checked cached rows — the registry skips `apply`
+   * for the already-folded prefix (events at or below the row's `seq`) — and
+   * the refreshed checkpoint is written back (fail-soft, fire-and-forget), so
+   * the first cold read creates the cache row and later ones seed from it.
+   * The caller supplies the complete log in seq order: this service never
+   * consults the persistence layer.
+   * @param meta - the stored session header (identity witness).
+   * @param events - the session's complete log, in seq order.
+   * @returns the projection cut at the log end.
    */
-  async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
-    const record = this.requireTable().get(id)
-    const cached = record?.rows ?? {}
-    const floor = this.ctx.sessionProjections.restoreFloor(cached)
-    const persistence = this.ctx.sessionPersistence
-    if (floor === undefined) {
-      // No unit registered: nothing to fold, but the not-found contract must
-      // hold in this topology too — the probe read rejects for an absent log
-      // and dates the empty cut for a present one.
-      const probe = await persistence.readFrom(id, 0, signal)
-      return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} }
-    }
-    let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
-    const tail = await persistence.readFrom(id, floor, signal)
-    // The tail's stored header is the identity witness: a record bound to a
-    // different lifecycle (recreated id, swapped store) is discarded whole
-    // before any of its rows can seed a fold.
-    const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
-    try {
-      if (!related) throw new Error('unrelated log identity')
-      restored = this.ctx.sessionProjections.restore(cached, tail.events, floor, tail.meta)
-    } catch {
-      // Recoverable failures are an unrelated record, a row outside the
-      // supplied suffix or log end, and stateSchema rejection. The full read
-      // removes every checkpoint seed and lets each unit refold from init.
-      const whole = await persistence.readFrom(id, 0, signal)
-      restored = this.ctx.sessionProjections.restore({}, whole.events, 0, whole.meta)
-    }
-    await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
+  coldSnapshot(meta: SessionHeader, events: readonly SessionEvent[]): ProjectionSnapshot {
+    const restored = this.ctx.sessionProjections.restore(this.recordFor(meta.id, identityOf(meta))?.rows ?? {}, events, 0, meta)
+    // Refresh the row so the next cold read seeds from it; fail-soft and
+    // fire-and-forget — a failed write-back only costs a longer tail replay.
+    void this.put(meta.id, identityOf(meta), restored.checkpoint).catch((error: unknown) => {
+      this.ctx.logger.warn(`session projection cache: cold-read write-back for "${meta.id}" failed (cache stays stale): ${String(error)}`)
+    })
     return restored.snapshot
   }
 
+
   // --- write-behind (throttle + mandatory points) ---
 
   private installWritePath(): void {
@@ -254,6 +238,15 @@ export class SessionProjectionCache extends Service {
       }, this.config.writeIntervalMs)
     })
 
+    // Creation is the FIRST mandatory point: a session that never talks (a
+    // forked child seeded with its ancestor's title, say) would otherwise
+    // get its first row only at detach — so a crash, or a fork held live in
+    // the store, would leave the seed-derived values (the title) unreadable
+    // on the cold list. The creation write captures the seed-derived cut.
+    this.ctx.on('session/created', (session: Session) => {
+      void this.flushSoft(session, 'create')
+    })
+
     // Detach (the live-to-cold moment): the second mandatory point. After
     // this write the cold-read ladder serves the session from the cache.
     // flushSoft's synchronous prefix reads and resets the dirty state, so
@@ -264,7 +257,11 @@ export class SessionProjectionCache extends Service {
       this.dirty.delete(session)
     })
 
-    // Clear pending timers with the plugin (their sessions outlive the cache).
+    // With the plugin (their sessions outlive the cache): clear pending
+    // timers and stop accepting new work. The domain-close effect registered
+    // in init runs after this disposer and drains already-queued writes, so
+    // a late flush can never land after disposal (it rejects `closed` into
+    // flushSoft's warning instead).
     this.ctx.effect(() => () => {
       for (const state of this.dirty.values()) {
         if (state.timer !== undefined) clearTimeout(state.timer)
@@ -276,7 +273,7 @@ export class SessionProjectionCache extends Service {
   /**
    * One fail-soft durable checkpoint. Every caller has work by construction:
    * the throttle triggers only fire dirty (markClean clears the timer with
-   * the counter) and the two mandatory points write unconditionally.
+   * the counter) and the mandatory points write unconditionally.
    */
   private async flushSoft(session: Session, trigger: string): Promise<void> {
     try {
@@ -306,15 +303,6 @@ export class SessionProjectionCache extends Service {
     await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] })
   }
 
-  /** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */
-  private async putSoft(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint, what: string): Promise<void> {
-    try {
-      await this.put(id, identity, rows)
-    } catch (error) {
-      this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`)
-    }
-  }
-
   private requireTable(): KvTable<SessionId, CheckpointRecord> {
     /* v8 ignore next -- Service.init assigns the table before the service becomes injectable */
     if (this.table === undefined) throw new Error('session projection cache is not initialized')

+ 2 - 2
packages/session/session-projection-cache/src/invariant.ts

@@ -19,8 +19,8 @@ export const inject = ['invariants']
  * the registry fold at its `seq` watermark) is only checkable by re-running the
  * fold over the persisted log — duplicating the implementation rather than
  * detecting drift — and its staleness is by design (fail-soft writes). The
- * durable boundary is already schema-validated by the storage-domain layer
- * on every reopen, and the read ladder's version/watermark guards are proven
+ * durable boundary is schema-validated by the cache's own zod parse on every
+ * read, and the read ladder's version/watermark guards are proven
  * by the package spec.
  */
 const install: InvariantInstaller = () => {}

+ 17 - 13
packages/session/session-projection-cache/src/spec.ts

@@ -1,11 +1,12 @@
 /**
- * The session-projcache domain declaration: one `sessions` table keyed by
+ * The projection-cache domain declaration: one `sessions` table keyed by
  * {@link SessionId}, each record the full projection checkpoint for one
- * session (`key → {ver, seq, val}` rows). The spec object
- * is the single source of the domain's identity, version, and record schema;
- * the storage-domain routing decides the medium (the shipped composition's
- * json backend lands it at `<root>/session_projcache.json`, beside
- * `workspace.json`).
+ * session (`key → {ver, seq, val}` rows). The spec object is the single
+ * source of the domain's identity, version, layout, and record schema; the
+ * storage-domain routing decides the medium (the shipped composition's json
+ * backend stores the domain `per-record`: one document per session under
+ * `<root>/session_projcache/sessions/`, so a checkpoint write rewrites one
+ * session's document instead of the whole unit).
  * @module @deepseek-ai/dsh-session-projection-cache/src/spec
  */
 
@@ -32,9 +33,9 @@ export const checkpointRow = z.object({
  * that distinguish one session lifecycle from another under the same id. A
  * session id names a slot, not a lifecycle — a deleted-then-recreated id, or
  * a persistence root swapped under a surviving cache, would otherwise let an
- * old row pass every watermark check and seed state folded from an unrelated
- * log. Reads validate this against the live header (listing) or the stored
- * header (cold read) before accepting any row.
+ * old record pass every watermark check and seed state folded from an
+ * unrelated log. Reads validate this against the live header (listing) or
+ * the stored header (cold read) before accepting any record.
  */
 export const checkpointIdentity = z.object({
   createdAt: z.number().int().nonnegative(),
@@ -59,12 +60,15 @@ export const checkpointRecord = z.object({
 export type CheckpointRecord = z.infer<typeof checkpointRecord>
 
 /**
- * The session-projcache domain spec. Version bumps discard the whole medium
- * (cache semantics: a stale or unreadable cache costs a longer tail replay,
- * never a wrong value).
+ * The session-projcache domain spec. The `per-record` layout scopes version
+ * bumps per session: after a bump, a stale session document is discarded on
+ * open (cache semantics — a stale or unreadable cache costs a longer tail
+ * replay, never a wrong value) while the rest of the domain stays usable,
+ * instead of rejecting the whole medium.
  */
 export const projectionCacheDomainSpec = defineDomain({
   name: 'session_projcache',
-  version: 3,
+  version: 4,
+  layout: 'per-record',
   tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
 })

+ 256 - 230
packages/session/session-projection-cache/tests/cache.spec.ts

@@ -2,26 +2,40 @@
  * SessionProjectionCache behavior: mandatory-point writes (turn/end, detach),
  * count/interval throttling between them, fail-soft durability (a failed
  * write logs and stays stale, never throws into the event path), and the
- * cold-read ladder (cached row + readFrom tail + registry restore +
- * write-back; version bump and shrunk-log rows degrade to a full re-read).
+ * synchronous cached listing read. The durable medium is the
+ * `session_projcache` storage domain in per-record layout: one
+ * version-stamped document per session under the json backend root at
+ * `<root>/session_projcache/sessions/<id>.json`. Reads never touch the
+ * medium — they come from the domain's in-memory tables, which writes mutate
+ * only after durability.
  */
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { z } from 'zod'
-import Storage from '@deepseek-ai/dsh-storage'
-import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
 import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
-import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
+import Storage from '@deepseek-ai/dsh-storage'
+import {
+  apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName,
+} from '@deepseek-ai/dsh-storage-json'
+import {
+  apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName,
+} from '@deepseek-ai/dsh-storage-domain'
 import SessionProjectionCache from '../src/index.ts'
+import { checkpointRecord, projectionCacheDomainSpec } from '../src/spec.ts'
+import type { CheckpointRecord } from '../src/spec.ts'
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
     'cache-test/marks': MarksState
     'cache-test/marks2': Map<string, string>
+    'cache-test/count': number
   }
   interface SessionProjectionMap {
     'cache-test/marks': { marks: string[] }
@@ -51,49 +65,38 @@ const marksUnit = (stateVersion = 1) => ({
   stateVersion,
 }) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
 
-/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
-function fakePersistence(logs: Map<string, SessionEvent[]>) {
-  const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => {
-    const events = logs.get(String(id))
-    if (events === undefined) throw new Error(`session "${id}" not found`)
-    return {
-      meta: { version: 0, id, createdAt: 0 },
-      events: events.filter(event => event.seq >= fromSeq),
-    }
-  })
-  return { readFrom }
-}
+/** One session's record document on the per-record medium. */
+const recordPath = (root: string, id: Session['id']): string =>
+  join(root, projectionCacheDomainSpec.name, 'sessions', `${String(id)}.json`)
 
-/** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */
+/** Header shape for cachedSnapshot calls. */
 const headerOf = (id: SessionId, createdAt = 0, cwd?: string) =>
   ({ version: 0, id, createdAt, ...cwd === undefined ? {} : { cwd } })
 
 interface HarnessOptions {
-  pool?: MemoryMediaPool
+  root?: string
   config?: { writeEveryEvents: number; writeIntervalMs: number }
   stateVersion?: number
-  logs?: Map<string, SessionEvent[]>
 }
 
 const contexts: Context[] = []
+const roots: string[] = []
 
 async function harness(options: HarnessOptions = {}) {
-  const pool = options.pool ?? new MemoryMediaPool()
-  const logs = options.logs ?? new Map<string, SessionEvent[]>()
+  const root = options.root ?? await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+  roots.push(root)
   const ctx = new Context()
   contexts.push(ctx)
+  // The cache opens its domain through the storage stack; the json backend
+  // lands the per-record tree under this tmp root.
   await ctx.plugin(Storage)
-  ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
-  const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
-  ctx.storage.mount('domain', facility)
-  ctx.provide('storageDomain', facility)
+  await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+  await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
   await ctx.plugin(SessionStore)
   await ctx.plugin(SessionProjectionRegistry)
   ctx.sessionProjections.register(marksUnit(options.stateVersion))
-  const persistence = fakePersistence(logs)
-  ctx.provide('sessionPersistence', persistence as never)
   const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
-  return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache }
+  return { ctx, root, fiber, cache: ctx.sessionProjectionCache }
 }
 
 const mark = (session: Session, marks: string[]): SessionEvent =>
@@ -102,42 +105,72 @@ const mark = (session: Session, marks: string[]): SessionEvent =>
 const endTurn = (session: Session): SessionEvent =>
   session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
 
-/** The stored medium record for one session id (undefined = never written). */
-function storedRecord(pool: MemoryMediaPool, id: Session['id']) {
-  return pool.media.get('session_projcache')?.tables.get('sessions')?.get(String(id)) as
-    {
-      identity: { createdAt: number; cwd?: string }
-      rows: Record<string, { ver: number; seq: number; val: unknown }>
-    } | undefined
+/** The stored record for one session id (undefined = absent or unreadable). */
+async function storedRecord(root: string, id: Session['id']): Promise<CheckpointRecord | undefined> {
+  try {
+    const document = JSON.parse(await readFile(recordPath(root, id), 'utf8')) as { record: unknown }
+    return checkpointRecord.parse(document.record)
+  } catch {
+    return undefined
+  }
 }
 
-/** The stored medium rows for one session id (undefined = never written). */
-function storedRows(pool: MemoryMediaPool, id: Session['id']) {
-  return storedRecord(pool, id)?.rows
+/** The stored rows for one session id (undefined = absent or unreadable). */
+async function storedRows(root: string, id: Session['id']): Promise<CheckpointRecord['rows'] | undefined> {
+  return (await storedRecord(root, id))?.rows
 }
 
-/** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */
-const settle = () => new Promise(resolve => setTimeout(resolve, 0))
+/** Pre-seed one session's record document with a stored checkpoint record. */
+async function seedRecord(
+  root: string,
+  id: string,
+  rows: CheckpointRecord['rows'],
+  identity: CheckpointRecord['identity'] = { createdAt: 0 },
+): Promise<void> {
+  const path = recordPath(root, SessionId(id))
+  await mkdir(dirname(path), { recursive: true })
+  await writeFile(path, JSON.stringify({ version: projectionCacheDomainSpec.version, record: { identity, rows } }))
+}
+
+/** Wait until queued fail-soft writes (event-listener fire-and-forget over real fs I/O) drain. */
+const settle = () => new Promise(resolve => setTimeout(resolve, 40))
 
 afterEach(async () => {
   vi.useRealTimers()
   await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })))
 })
 
 describe('SessionProjectionCache write policy', () => {
   it('writes a durable checkpoint at turn/end (mandatory point)', async () => {
-    const { ctx, pool } = await harness()
+    const { ctx, root } = await harness()
     const session = ctx.sessions.create(SessionId('turn-end'))
     mark(session, ['a'])
-    expect(storedRows(pool, session.id)).toBeUndefined() // throttled: no write yet
+    // Creation already wrote the init cut; the mark is throttled, so the
+    // stored row is still the creation-time cut (no marks folded).
+    await settle()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1)
     const end = endTurn(session)
     await settle()
-    const rows = storedRows(pool, session.id)
+    const rows = await storedRows(root, session.id)
     expect(rows?.['cache-test/marks']).toEqual({ ver: 1, seq: end.seq, val: { marks: ['a'] } })
   })
 
+  it('writes a checkpoint at session creation, capturing the seed-derived cut', async () => {
+    const { ctx, root } = await harness()
+    // A forked child seeded with its ancestor's title-like event: no
+    // conversation follows, yet the creation write must capture the fold so
+    // a crash or a live-held fork still lists the derived value.
+    const session = ctx.sessions.create(SessionId('seeded'), {
+      seed: [{ type: 'cache-test/mark', seq: 0, time: 1, data: { marks: ['seed'] } }] as SessionEvent[],
+    })
+    await settle()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val)
+      .toEqual({ marks: ['seed'] })
+  })
+
   it('writes at session disposal (detach, the live-to-cold moment)', async () => {
-    const { ctx, pool } = await harness()
+    const { ctx, root } = await harness()
     // Sessions dispose with their owning fiber: create in a child plugin.
     let session: Session | undefined
     const owner = await ctx.plugin(Object.assign((inner: Context) => {
@@ -147,39 +180,37 @@ describe('SessionProjectionCache write policy', () => {
     mark(session, ['live'])
     await owner.dispose()
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
   })
 
   it('flushes when the in-turn event count reaches the configured threshold', async () => {
-    const { ctx, pool } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
+    const { ctx, root } = await harness({ config: { writeEveryEvents: 3, writeIntervalMs: 60_000 } })
     const session = ctx.sessions.create(SessionId('count'))
     mark(session, ['1'])
     mark(session, ['2'])
     await settle()
-    expect(storedRows(pool, session.id)).toBeUndefined()
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut
     mark(session, ['3'])
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
   })
 
   it('flushes on the configured interval when the count threshold is not reached', async () => {
-    vi.useFakeTimers()
-    const { ctx, pool } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 250 } })
+    const { ctx, root } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 20 } })
     const session = ctx.sessions.create(SessionId('interval'))
     mark(session, ['slow'])
-    await vi.advanceTimersByTimeAsync(249)
-    expect(storedRows(pool, session.id)).toBeUndefined()
-    await vi.advanceTimersByTimeAsync(1)
-    await vi.advanceTimersByTimeAsync(0)
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
+    await new Promise(resolve => setTimeout(resolve, 10)) // before the interval
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.seq).toBe(-1) // still the creation cut
+    await settle() // past the interval; the fire-and-forget write lands
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['slow'] })
   })
 
   it('write() on a never-dirty session checkpoints directly and rejects a non-JSON unit state', async () => {
-    const { ctx, pool } = await harness()
+    const { ctx, root } = await harness()
     // Never dirtied: no events — write() still lands the init-derived cut.
     const clean = ctx.sessions.create(SessionId('clean-write'))
     await ctx.sessionProjectionCache.write(clean)
-    expect(storedRows(pool, clean.id)?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
+    expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null })
     // A unit whose state violates the plain-JSON contract fails the write loud.
     ctx.sessionProjections.register({
       key: 'cache-test/marks2',
@@ -193,7 +224,7 @@ describe('SessionProjectionCache write policy', () => {
 
   it('plugin disposal clears armed interval timers and leaves cleaned sessions alone', async () => {
     vi.useFakeTimers()
-    const { ctx, pool, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
+    const { ctx, root, fiber } = await harness({ config: { writeEveryEvents: 100, writeIntervalMs: 5000 } })
     const armed = ctx.sessions.create(SessionId('armed'))
     const cleaned = ctx.sessions.create(SessionId('cleaned'))
     mark(armed, ['pending']) // timer armed, no write yet
@@ -203,28 +234,105 @@ describe('SessionProjectionCache write policy', () => {
     await fiber.dispose()
     // The armed timer died with the plugin: advancing time writes nothing.
     await vi.advanceTimersByTimeAsync(10_000)
-    expect(storedRows(pool, armed.id)).toBeUndefined()
+    // Only the creation cut exists: the armed mark never wrote.
+    expect((await storedRows(root, armed.id))?.['cache-test/marks']?.seq).toBe(-1)
   })
 
   it('contains a durable write failure: logs a warning, event path unharmed, next write self-heals', async () => {
-    const { ctx, pool } = await harness()
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Storage)
+    await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+    await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
+    ctx.sessionProjections.register(marksUnit())
+    await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    // A directory where the record document must land makes the atomic
+    // rename fail — including the creation write, so no row ever lands.
+    const blocker = recordPath(root, SessionId('fail-soft'))
+    await mkdir(blocker, { recursive: true })
     const session = ctx.sessions.create(SessionId('fail-soft'))
     mark(session, ['x'])
-    pool.failNextWrites = 1
     endTurn(session)
     await settle()
-    expect(storedRows(pool, session.id)).toBeUndefined()
+    expect(await storedRows(root, session.id)).toBeUndefined()
     expect(warn).toHaveBeenCalledWith(expect.stringContaining('turn/end write for "fail-soft" failed'))
-    // Self-heal: the next mandatory point writes the current cut.
+    // Self-heal: once the blocker clears, the next mandatory point writes.
+    await rm(recordPath(root, session.id), { recursive: true })
     mark(session, ['y'])
     endTurn(session)
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
+    expect((await storedRows(root, session.id))?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
   })
 })
 
-describe('SessionProjectionCache cold read', () => {
+describe('SessionProjectionCache listing read', () => {
+  it('serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    await seedRecord(root, 'listed', { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['t'] } } })
+    const { cache } = await harness({ root })
+    const id = SessionId('listed')
+    // Matching header: values plus the watermark the client seeds under.
+    expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
+    // A recreated id (different createdAt): the record is unrelated — no block.
+    expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
+    // Unknown id: no block.
+    expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
+  })
+
+  it('returns undefined when the stored record is version-mismatched', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // A stale version-stamped document is discarded at open: absent record.
+    const path = recordPath(root, SessionId('all-stale'))
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, JSON.stringify({
+      version: projectionCacheDomainSpec.version + 1,
+      record: { identity: { createdAt: 0 }, rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['old'] } } } },
+    }))
+    const { cache } = await harness({ root })
+    expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
+  })
+
+  it('returns undefined when every stored row is version-mismatched', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // A current document whose rows all fail the live unit's stateVersion:
+    // the listing view is empty, so no block is served.
+    await seedRecord(root, 'row-stale', { 'cache-test/marks': { ver: 99, seq: 4, val: { marks: ['old'] } } })
+    const { cache } = await harness({ root })
+    expect(cache.cachedSnapshot(headerOf(SessionId('row-stale')))).toBeUndefined()
+  })
+
+  it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    await seedRecord(root, 'homed', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['w'] } } }, { createdAt: 0, cwd: '/work' })
+    const { cache } = await harness({ root })
+    const id = SessionId('homed')
+    expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
+    expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
+    expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
+  })
+
+  it('returns undefined for a malformed record document (refold from the log on the caller side)', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    const path = recordPath(root, SessionId('malformed'))
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, 'not json at all')
+    const { cache } = await harness({ root })
+    expect(cache.cachedSnapshot(headerOf(SessionId('malformed')))).toBeUndefined()
+  })
+})
+
+describe('SessionProjectionCache cold-read seeding', () => {
+  /** One session's event log: turn/start, one mark per group, turn/end. */
   const storedLog = (marks: string[][]): SessionEvent[] => {
     const events: SessionEvent[] = [
       { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
@@ -236,186 +344,104 @@ describe('SessionProjectionCache cold read', () => {
     return events
   }
 
-  /** Pre-seed the medium with one stored checkpoint record (before the domain opens). */
-  function seedRow(
-    pool: MemoryMediaPool,
-    id: string,
-    row: { ver: number; seq: number; val: unknown },
-    identity: { createdAt: number; cwd?: string } = { createdAt: 0 },
-  ): void {
-    pool.versions.set('session_projcache', 3)
-    pool.media.set('session_projcache', {
-      tables: new Map([['sessions', new Map([[id, { identity, rows: { 'cache-test/marks': row } }]])]]),
-      global: null,
+  it('hydratePrepared seeds from a matching row and retries from the exact log on a malformed one', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // Records land on disk before the domain opens, so the in-memory table
+    // picks them up at init.
+    await seedRecord(root, 'prepared-seeded', {
+      'cache-test/marks': { ver: 1, seq: 1, val: { marks: ['cached'] } },
     })
-  }
-
-  it('retries prepared hydration without a malformed cached checkpoint', async () => {
-    const pool = new MemoryMediaPool()
-    const id = SessionId('prepared-cache-fallback')
-    seedRow(pool, id, { ver: 1, seq: 1, val: { marks: 'malformed' } })
+    await seedRecord(root, 'prepared-fallback', {
+      'cache-test/marks': { ver: 1, seq: 1, val: { marks: 'malformed' } },
+    })
+    const { cache } = await harness({ root })
     const events = storedLog([['fresh']])
-    const { cache } = await harness({ pool })
-    const meta = headerOf(id)
-    const session = Session.create(id, events, meta)
 
-    expect(cache.hydratePrepared(session, meta, events)).toEqual({
+    // A matching row hydrates the prepared Session without a persistence read.
+    const seeded = headerOf(SessionId('prepared-seeded'))
+    const seededSession = Session.create(seeded.id, events, seeded)
+    expect(cache.hydratePrepared(seededSession, seeded, events)).toEqual({
       asOfSeq: 2,
-      values: { 'cache-test/marks': { marks: ['fresh'] } },
+      values: { 'cache-test/marks': { marks: ['cached'] } },
     })
-  })
 
-  it('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]])
-    // A warm-era checkpoint at watermark 1 (only ['a'] folded).
-    seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } })
-    const { cache, persistence, pool: samePool } = await harness({ pool, logs })
-    const id = SessionId('cold')
-    const snapshot = await cache.coldSnapshot(id)
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a', 'b'] })
-    expect(snapshot.asOfSeq).toBe(3)
-    // The tail read was bounded by the anchored floor (watermark 1 -> floor 1), not 0.
-    expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined)
-    // Write-back: the stored row advanced to the served cut.
-    expect(storedRows(samePool, id)?.['cache-test/marks'])
-      .toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } })
-  })
-
-  it('discards a version-mismatched row and refolds the full log', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['bumped', storedLog([['a']])]])
-    seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } })
-    const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 })
-    const snapshot = await cache.coldSnapshot(SessionId('bumped'))
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
-    // Mismatch pulls the floor to 0: one full read, no second pass needed.
-    expect(persistence.readFrom).toHaveBeenCalledTimes(1)
-    expect(persistence.readFrom).toHaveBeenCalledWith(SessionId('bumped'), 0, undefined)
-  })
-
-  it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2
-    seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } })
-    const { cache, persistence } = await harness({ pool, logs })
-    const snapshot = await cache.coldSnapshot(SessionId('shrunk'))
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
-    expect(snapshot.asOfSeq).toBe(2)
-    // Anchored tail read (floor 9) came back empty -> full re-read from 0.
-    expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('shrunk'), 9, undefined)
-    expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined)
-  })
-
-  it('discards malformed persisted state and degrades to one full re-read', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['malformed', storedLog([['real']])]])
-    seedRow(pool, 'malformed', { ver: 1, seq: 1, val: { marks: 'not-an-array' } })
-    const { cache, persistence } = await harness({ pool, logs })
-
-    const snapshot = await cache.coldSnapshot(SessionId('malformed'))
-
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
-    expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('malformed'), 1, undefined)
-    expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined)
-  })
-
-  it('write-back failure is contained: the snapshot is still served', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['soft', storedLog([['a']])]])
-    const { ctx, cache } = await harness({ pool, logs })
-    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
-    pool.failNextWrites = 1
-    const snapshot = await cache.coldSnapshot(SessionId('soft'))
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
-    expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "soft" failed'))
-  })
-
-  it('rejects for a session with no persisted log', async () => {
-    const { cache } = await harness()
-    await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
-  })
-
-  it('discards a record bound to a different log lifecycle and refolds from the actual log', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['reborn', storedLog([['real']])]]) // stored header stamps createdAt 0
-    // A checkpoint from a PRIOR lifecycle of the same id (different createdAt):
-    // its rows pass every watermark check, but the identity does not match.
-    seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 })
-    const { cache, pool: samePool } = await harness({ pool, logs })
-    const snapshot = await cache.coldSnapshot(SessionId('reborn'))
-    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
-    // The write-back rebinds the record to the actual log's identity.
-    expect(storedRecord(samePool, SessionId('reborn'))?.identity).toEqual({ createdAt: 0 })
-  })
-
-  it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => {
-    const pool = new MemoryMediaPool()
-    seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } })
-    const { cache } = await harness({ pool })
-    expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
-  })
-
-  it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
-    const pool = new MemoryMediaPool()
-    seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
-    const { cache } = await harness({ pool })
-    const id = SessionId('homed')
-    expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
-    expect(cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
-    expect(cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
-  })
+    // A malformed row cannot seed the fold; hydration falls back to the
+    // exact log so a valid Session stays readable.
+    const fallback = headerOf(SessionId('prepared-fallback'))
+    const fallbackSession = Session.create(fallback.id, events, fallback)
+    expect(cache.hydratePrepared(fallbackSession, fallback, events)).toEqual({
+      asOfSeq: 2,
+      values: { 'cache-test/marks': { marks: ['fresh'] } },
+    })
 
-  it('dates an empty stored log at -1 in the zero-units topology', async () => {
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['empty', [] as SessionEvent[]]])
-    const ctx = new Context()
-    contexts.push(ctx)
-    await ctx.plugin(Storage)
-    ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
-    const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
-    ctx.storage.mount('domain', facility)
-    ctx.provide('storageDomain', facility)
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    ctx.provide('sessionPersistence', fakePersistence(logs) as never)
-    await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
-    await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('empty')))
-      .resolves.toEqual({ asOfSeq: -1, values: {} })
+    // No row at all: hydrate from init over the exact log.
+    const bare = headerOf(SessionId('prepared-bare'))
+    const bareSession = Session.create(bare.id, events, bare)
+    expect(cache.hydratePrepared(bareSession, bare, events)).toEqual({
+      asOfSeq: 2,
+      values: { 'cache-test/marks': { marks: ['fresh'] } },
+    })
   })
 
-  it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
-    const pool = new MemoryMediaPool()
-    seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } })
-    const { cache } = await harness({ pool })
-    const id = SessionId('listed')
-    // Matching header: values plus the watermark the client seeds under.
-    expect(cache.cachedSnapshot(headerOf(id))).toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['t'] } } })
-    // A recreated id (different createdAt): the record is unrelated — no block.
-    expect(cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
-    // Unknown id: no block.
-    expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
+  it('coldSnapshot traverses the full log but applies only the events after each cached watermark', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // A cached row covering the prefix through seq 2 (three applies folded).
+    await seedRecord(root, 'cold-snap', {
+      'cache-test/count': { ver: 1, seq: 2, val: 3 },
+    }, { createdAt: 9 })
+    const { cache, ctx } = await harness({ root })
+    const apply = vi.fn((_state: number, _event: SessionEvent) => 1)
+    ctx.sessionProjections.register({
+      key: 'cache-test/count',
+      stateSchema: z.number().int().nonnegative(),
+      init: () => 0,
+      apply,
+      stateVersion: 1,
+    } satisfies ProjectionDefinition<'cache-test/count', number>)
+    const meta = headerOf(SessionId('cold-snap'), 9)
+    const events = Array.from({ length: 5 }, (_, seq) => ({
+      type: 'cache-test/mark', seq, time: seq, data: { marks: [`m${seq}`] },
+    })) as SessionEvent[]
+    const snapshot = cache.coldSnapshot(meta, events)
+    // The full log was traversed, but the fold applied only seqs 3 and 4.
+    expect(apply).toHaveBeenCalledTimes(2)
+    expect(apply.mock.calls.map(call => call[1].seq)).toEqual([3, 4])
+    expect(snapshot.asOfSeq).toBe(4)
+    // Host-only unit: folded but not served; the refreshed row is written
+    // back (fail-soft, fire-and-forget) once the write lands.
+    expect(Object.keys(snapshot.values)).not.toContain('cache-test/count')
+    await settle()
+    expect((await storedRows(root, meta.id))?.['cache-test/count']?.seq).toBe(4)
+    // No cached row yet: the first cold read folds from init over the full
+    // log and creates the cache row (the `?? {}` seed path).
+    const fresh = headerOf(SessionId('cold-fresh'), 10)
+    cache.coldSnapshot(fresh, events)
+    expect(apply).toHaveBeenCalledTimes(7) // 2 tail + 5 full
+    await settle()
+    expect((await storedRows(root, fresh.id))?.['cache-test/count']?.seq).toBe(4)
   })
 
-  it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => {
-    // Same composition minus any registered unit: restoreFloor is undefined,
-    // yet coldSnapshot must still reject for an absent log (probe read) and
-    // serve an empty cut at the stored end for a present one.
-    const pool = new MemoryMediaPool()
-    const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
+  it('coldSnapshot write-back is fail-soft: a failed durable write logs and never throws', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     const ctx = new Context()
     contexts.push(ctx)
     await ctx.plugin(Storage)
-    ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
-    const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
-    ctx.storage.mount('domain', facility)
-    ctx.provide('storageDomain', facility)
+    await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+    await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
     await ctx.plugin(SessionStore)
     await ctx.plugin(SessionProjectionRegistry)
-    ctx.provide('sessionPersistence', fakePersistence(logs) as never)
+    ctx.sessionProjections.register(marksUnit())
     await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
-    await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
-    await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare')))
-      .resolves.toEqual({ asOfSeq: 2, values: {} })
+    const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
+    // A directory where the record document must land makes the write-back
+    // fail; the cold read itself still succeeds and never throws.
+    const meta = headerOf(SessionId('cold-fail'))
+    await mkdir(recordPath(root, meta.id), { recursive: true })
+    expect(ctx.sessionProjectionCache.coldSnapshot(meta, [])).toBeDefined()
+    await settle()
+    expect(warn).toHaveBeenCalledWith(expect.stringContaining('cold-read write-back for "cold-fail" failed'))
   })
 })

+ 7 - 7
packages/session/session-projection-cache/tsconfig.json

@@ -9,10 +9,10 @@
   ],
   "references": [
     {
-      "path": "../../../vendor/cosmokit"
+      "path": "../../../vendor/cordis"
     },
     {
-      "path": "../../../vendor/cordis"
+      "path": "../../../vendor/cosmokit"
     },
     {
       "path": "../../../vendor/schemastery"
@@ -21,10 +21,7 @@
       "path": "../../core/session"
     },
     {
-      "path": "../session-persistence"
-    },
-    {
-      "path": "../session-projection"
+      "path": "../../runtime-diagnostics/invariants"
     },
     {
       "path": "../../storage/storage"
@@ -33,7 +30,10 @@
       "path": "../../storage/storage-domain"
     },
     {
-      "path": "../../runtime-diagnostics/invariants"
+      "path": "../../storage/storage-json"
+    },
+    {
+      "path": "../session-projection"
     }
   ]
 }

+ 17 - 0
packages/storage/storage-domain/src/spec.ts

@@ -37,6 +37,14 @@ export interface DomainSpec {
   readonly name: string
   /** Domain format version; a medium stamped with a different version rejects at open. */
   readonly version: number
+  /**
+   * Medium layout for the backend unit: `single` (the default) stores the
+   * whole unit as one document; `per-record` stores each record as its own
+   * document, for units whose records are large, sparse, or individually
+   * disposable — the projection cache — and scopes version bumps per record
+   * (a stale record document is discarded, never migrated).
+   */
+  readonly layout?: 'single' | 'per-record'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
@@ -83,6 +91,14 @@ export function defineDomain<S extends DomainSpec>(spec: S): S {
   if (!Number.isInteger(spec.version) || spec.version < 0) {
     throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`)
   }
+  if (spec.layout !== undefined) {
+    // Runtime boundary: the union type is compile-time only — a spec built
+    // from config could carry any value, and a bad one must fail loud here.
+    const layout: string = spec.layout
+    if (layout !== 'single' && layout !== 'per-record') {
+      throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${layout}`)
+    }
+  }
   for (const table of Object.keys(spec.tables)) {
     if (!UNIT_NAME_RE.test(table)) {
       throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`)
@@ -108,5 +124,6 @@ export function descriptorOf(spec: DomainSpec): KvUnitDescriptor {
     version: spec.version,
     tables: Object.keys(spec.tables),
     hasGlobal: spec.global !== undefined,
+    ...spec.layout === undefined ? {} : { layout: spec.layout },
   }
 }

+ 12 - 1
packages/storage/storage-domain/tests/domain.spec.ts

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import { z } from 'zod'
 import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
-import { apply, DomainFacility, defineDomain, domainTable } from '../src/index.ts'
+import { apply, defineDomain, descriptorOf, DomainFacility, domainTable } from '../src/index.ts'
 import type { Config } from '../src/index.ts'
 import type { DomainChanged } from '../src/events.ts'
 import { MemoryMediaPool, MemoryStorageBackend } from './helpers/memory-backend.ts'
@@ -57,6 +57,17 @@ describe('defineDomain', () => {
       tables: {},
     })).toThrow(/must not accept null/)
   })
+
+  it('rejects an invalid layout and projects the declared one onto the descriptor', () => {
+    // A spec built from config can carry any value; the union type is
+    // compile-time only, so the runtime boundary check must reject it.
+    expect(() => defineDomain({ name: 'ok', version: 1, layout: 'every-record' as 'single', tables: {} }))
+      .toThrow(/layout/)
+    expect(descriptorOf(defineDomain({ name: 'per', version: 1, layout: 'per-record', tables: {} })))
+      .toMatchObject({ name: 'per', layout: 'per-record' })
+    // The default (single) layout is absent from the descriptor.
+    expect(descriptorOf(spec)).not.toHaveProperty('layout')
+  })
 })
 
 describe('DomainFacility.open', () => {

+ 2 - 2
packages/storage/storage-json/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/storage/storage-json/README.md
-README.md: 3a079da3f516af4a3f7bc13dcf0115589fa42ff3
-README.zh.md: f9b2ffb7444893a27a2da1d3dcd324c73452cc92
+README.md: d23362be7ad9d884a60b941663dfdd33668c300d
+README.zh.md: b90c78fa41302ce5f14a897b07cf1eb548e1843b

+ 9 - 3
packages/storage/storage-json/README.md

@@ -2,12 +2,18 @@
 
 English | [中文](README.zh.md)
 
-JSON backend for the [storage hub](../storage/README.md): one human-readable `<unit>.json` file per unit under a configured root, registered as backend `json`. Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md).
+JSON backend for the [storage hub](../storage/README.md), registered as backend `json`, serving two unit layouts:
+
+- **`single` (default)** — one human-readable `<unit>.json` file per unit under a configured root.
+- **`per-record`** — one version-stamped document per record at `<root>/<unit>/<table>/<key>.json` (plus `global.json`), so one write replaces one record instead of the whole unit; the unit is stateless (the directory is the state; `loadAll` re-reads the tree). Record keys must be path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects.
+
+Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md).
 
 ## Model
 
-- The in-memory unit state is authoritative; every write primitive republishes the whole file via temp-write + fsync + atomic `rename()` replace. A unit file is always the complete current net state — legibility is this backend's reason to exist; scale is the SQLite backend's job.
-- A missing file opens as an empty unit and materializes on the first write. A foreign or unparsable file rejects with `malformed-medium`; a stored version differing from the descriptor rejects with `version-mismatch` (no migration, pre-release stance).
+- `single` layout: the in-memory unit state is authoritative; every write primitive republishes the whole file via temp-write + fsync + atomic `rename()` replace. A unit file is always the complete current net state — legibility is this backend's reason to exist; scale is the SQLite backend's job.
+- A missing file (or unit directory, for `per-record`) opens as an empty unit and materializes on the first write. In `single` layout a foreign or unparsable file rejects with `malformed-medium`, and a stored version differing from the descriptor rejects with `version-mismatch` (no migration, pre-release stance). In `per-record` layout the contract is per record instead: a document that is malformed or stamped with another version reads as an absent record, so one bad or stale file never bricks the unit, and a version bump discards stale records rather than rejecting the whole unit.
+- An empty `per-record` tree bootstraps its declared-table records from a legacy `<unit>.json` whole-unit file and retains that file unchanged. Any new-layout document path in a declared table, or `global.json` for a declared global, suppresses the bootstrap for the whole unit even when the document is unreadable or stale; absent records remain absent instead of being filled from legacy state.
 - Write ordering across calls belongs to the caller (the domain layer's write chain); each single call is atomic and durable once resolved.
 
 ## Config

+ 9 - 3
packages/storage/storage-json/README.zh.md

@@ -2,12 +2,18 @@
 
 [English](README.md) | 中文
 
-[存储中心](../storage/README.zh.md)的 JSON 后端:配置根目录下每个单元使用一个人类可读的 `<unit>.json` 文件,注册为后端 `json`。设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
+[存储中心](../storage/README.zh.md)的 JSON 后端,注册为后端 `json`,提供两种单元布局:
+
+- **`single`(默认)**——配置根目录下每个单元使用一个人类可读的 `<unit>.json` 文件。
+- **`per-record`**——每个记录一个带版本戳的文档,位于 `<root>/<unit>/<table>/<key>.json`(外加 `global.json`),一次写入只替换一条记录而不是整个单元;该单元无状态(目录即状态,`loadAll` 重扫目录树)。记录键必须路径安全(`[a-zA-Z0-9_-]+`);不安全键会被拒绝。
+
+设计见[领域 KV 存储 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)。
 
 ## 模型
 
-- 内存中的单元状态具有最终决定权;每个写入原语都会通过临时文件写入 + fsync + 原子 `rename()` 替换重新发布整个文件。单元文件始终是完整的当前状态:可读性是该后端存在的理由,规模问题则属于 SQLite 后端。
-- 缺失文件会作为空单元打开,并在第一次写入时物化。外来或无法解析的文件以 `malformed-medium` 拒绝;已存版本与描述符不同时以 `version-mismatch` 拒绝(预发布立场,不迁移)。
+- `single` 布局:内存中的单元状态具有最终决定权;每个写入原语都会通过临时文件写入 + fsync + 原子 `rename()` 替换重新发布整个文件。单元文件始终是完整的当前状态:可读性是该后端存在的理由,规模问题则属于 SQLite 后端。
+- 缺失文件(`per-record` 时是缺失单元目录)会作为空单元打开,并在第一次写入时物化。`single` 布局下外来或无法解析的文件以 `malformed-medium` 拒绝;已存版本与描述符不同时以 `version-mismatch` 拒绝(预发布立场,不迁移)。`per-record` 布局的契约改为按记录:畸形或版本戳不符的文档读作"无此记录",单个坏文件或过期文件不会拖垮整个单元,版本升级按记录丢弃过期行而不是拒绝整个单元。
+- 空的 `per-record` 目录树会从旧 `<unit>.json` 整单元文件引导其已声明表的记录,并原样保留旧文件。已声明表中只要存在任意新布局文档路径,或已声明 global 对应的 `global.json` 存在,就会对整个单元禁用引导,即使该文档不可读或版本陈旧;缺失记录仍保持缺失,不从旧状态补入。
 - 跨调用的写入顺序属于调用方(领域层的写入链);每次调用都具备原子性,并在完成时已达到持久状态。
 
 ## 配置

+ 40 - 1
packages/storage/storage-json/src/format.ts

@@ -1,7 +1,11 @@
 /**
  * On-disk JSON unit format: the file is always the current net state, kept
  * human-readable (pretty-printed, stable key order from insertion) — that
- * legibility is this backend's reason to exist.
+ * legibility is this backend's reason to exist. `single`-layout units are
+ * one document with a unit header; `per-record`-layout units are a directory
+ * with one version-stamped document per record (`<table>/<key>.json`) plus a
+ * `global.json` for the global slot, so a write rewrites one record instead
+ * of the whole unit.
  * @module @deepseek-ai/dsh-storage-json/src/format
  */
 
@@ -82,3 +86,38 @@ export function parse(text: string, descriptor: KvUnitDescriptor): UnitState {
   }
   return state
 }
+
+/**
+ * Serialize one per-record document: the unit's version stamp plus the
+ * record value, pretty-printed like the whole-unit document.
+ * @param version - Unit format version, stamped into the header.
+ * @param value - The record value (or the global singleton value).
+ * @returns pretty-printed JSON document with a trailing newline.
+ */
+export function serializeRecord(version: number, value: unknown): string {
+  return `${JSON.stringify({ version, record: value }, null, 2)}\n`
+}
+
+/**
+ * Parse one per-record document, validating its version stamp. A document
+ * that is malformed or stamped with a different version is FOREIGN and reads
+ * as absent — the per-record contract: one bad or stale record file must not
+ * brick the whole unit, and a version bump discards stale records instead of
+ * migrating them (the whole-unit format rejects instead, because there is
+ * exactly one document).
+ * @param text - Raw per-record document content.
+ * @param version - Expected unit version; a mismatch discards the document.
+ * @returns the record value, or `undefined` for a foreign document.
+ */
+export function parseRecord(text: string, version: number): unknown {
+  let document: unknown
+  try {
+    document = JSON.parse(text)
+  } catch {
+    return undefined
+  }
+  if (typeof document !== 'object' || document === null) return undefined
+  const { version: stamped, record } = document as Record<string, unknown>
+  if (stamped !== version) return undefined
+  return record
+}

+ 13 - 8
packages/storage/storage-json/src/index.ts

@@ -1,17 +1,18 @@
 /**
- * JSON storage backend: one human-readable file per unit under a configured
- * root, published by atomic whole-file rewrite. Registers as backend `json`
- * on the storage hub.
+ * JSON storage backend: one human-readable document per unit under a
+ * configured root — a whole-unit file (`single` layout) or one document per
+ * record (`per-record` layout), published by atomic rewrite. Registers as
+ * backend `json` on the storage hub.
  * @module @deepseek-ai/dsh-storage-json
  */
 
 import { mkdir } from 'node:fs/promises'
-import { join } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage'
 import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
-import { openJsonUnit } from './unit.ts'
+import { openSingleUnit } from './single-unit.ts'
+import { openPerRecordUnit } from './per-record-unit.ts'
 
 /** Cordis plugin name. */
 export const name = 'storage-json'
@@ -25,7 +26,7 @@ export const inject = ['storage']
  * location explicitly.
  */
 export interface Config {
-  /** Directory holding one `<unit>.json` file per unit. */
+  /** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
   root: string
 }
 
@@ -62,8 +63,12 @@ export class JsonStorageBackend implements StorageBackend {
 
   private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
     await mkdir(this.root, { recursive: true, mode: 0o700 })
-    const path = join(this.root, `${descriptor.name}.json`)
-    const unit = await openJsonUnit(descriptor, path, () => this.open.delete(descriptor.name))
+    // The two layouts differ in medium shape only; each opener owns its own
+    // path convention under the shared root.
+    const onClose = () => this.open.delete(descriptor.name)
+    const unit = descriptor.layout === 'per-record'
+      ? await openPerRecordUnit(descriptor, this.root, onClose)
+      : await openSingleUnit(descriptor, this.root, onClose)
     if (this.closed) {
       // The backend closed while this open was in flight: do not hand out a
       // live unit past close().

+ 275 - 0
packages/storage/storage-json/src/per-record-unit.ts

@@ -0,0 +1,275 @@
+/**
+ * One opened JSON unit in `per-record` layout: the unit is a directory at
+ * `dir`, holding one document per record under `<dir>/<table>/<key>.json`
+ * plus `global.json` for the global slot. The directory is the state — this
+ * unit holds NO in-memory state of its own: `loadAll` re-reads the tree and
+ * every write is one durable file operation. The domain layer owns the live
+ * in-memory tables (seeded by the open-time `loadAll`) and serializes writes
+ * through its write chain, so this unit never mutates memory and needs no
+ * rollback — a failed write simply leaves both the file and the domain's
+ * memory unchanged.
+ *
+ * Per-record contract: a record document that is malformed or stamped with a
+ * different version reads as an absent record — one bad or stale file never
+ * bricks the whole unit, and a version bump discards stale records instead
+ * of migrating them. Record keys become path segments, so they must be
+ * path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
+ *
+ * Legacy bootstrap: when the new tree has no document path, a legacy
+ * whole-unit file `<root>/<name>.json` (the pre-per-record layout) seeds
+ * per-record documents. Any new document path, including one whose contents
+ * are unreadable or stale, suppresses the bootstrap for the whole unit. The
+ * legacy file is never changed or deleted.
+ * @module @deepseek-ai/dsh-storage-json/src/per-record-unit
+ */
+
+import { mkdir, readFile, readdir, rm } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import type { Dirent } from 'node:fs'
+import { StorageError } from '@deepseek-ai/dsh-storage'
+import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
+import { writeAtomic } from './atomic.ts'
+import { parseRecord, serializeRecord } from './format.ts'
+import type { UnitState } from './format.ts'
+
+/** Keys become path segments in this layout; this set is path-safe on every OS. */
+const SAFE_KEY_RE = /^[a-zA-Z0-9_-]+$/
+
+/**
+ * Open one `per-record`-layout unit under `root`: the unit directory is
+ * `<root>/<name>/`. Loads lazily on the first `loadAll` — this unit holds no
+ * state, so opening touches nothing on the medium.
+ * @param descriptor - Static identity and shape of the unit.
+ * @param root - Absolute backend root directory.
+ * @param onClose - Backend callback releasing the unit's open-slot.
+ * @returns the opened unit.
+ */
+// oxlint-disable-next-line typescript/require-await -- async keeps both openers' call sites uniform
+export async function openPerRecordUnit(
+  descriptor: KvUnitDescriptor,
+  root: string,
+  onClose: () => void,
+): Promise<KvUnit> {
+  return new PerRecordJsonUnit(descriptor, join(root, descriptor.name), onClose)
+}
+
+/**
+ * Read every record document under the unit directory: each declared table's
+ * `<key>.json` files plus `global.json`. A missing directory is the empty
+ * unit (materialization defers to the first write); a foreign document
+ * (missing, malformed, or stamped with another version) reads as an absent
+ * record, per the per-record contract.
+ * @param descriptor - Static identity and shape of the unit.
+ * @param dir - Absolute unit directory path.
+ * @returns the authoritative state reconstructed from the tree.
+ */
+async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Promise<UnitState> {
+  const state: UnitState = {
+    version: descriptor.version,
+    global: null,
+    tables: new Map(descriptor.tables.map(table => [table, new Map<string, unknown>()])),
+  }
+  let entries: Dirent[] | undefined
+  try {
+    entries = await readdir(dir, { withFileTypes: true })
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    // Missing directory = empty unit; the legacy bootstrap below still runs
+    // (the fresh-upgrade shape is exactly an absent new tree).
+  }
+  const hasNewDocuments = entries === undefined
+    ? false
+    : (await Promise.all(entries.map(async (entry) => {
+      if (entry.isDirectory()) {
+        const records = state.tables.get(entry.name)
+        if (records !== undefined) {
+          return loadTableRecords(records, descriptor.version, join(dir, entry.name))
+        }
+      }
+      if (entry.name === 'global.json' && descriptor.hasGlobal) {
+        const global = await readRecord(join(dir, entry.name), descriptor.version)
+        if (global !== undefined) state.global = global
+        return true
+      }
+      return false
+    }))).some(Boolean)
+  if (!hasNewDocuments) await bootstrapLegacyUnit(descriptor, dir, state)
+  return state
+}
+
+/**
+ * Bootstrap an empty per-record tree from a legacy whole-unit file
+ * (`<root>/<name>.json`, the pre-per-record layout). Every declared-table
+ * record is copied into a current-version document, while the legacy file is
+ * retained unchanged. A missing, foreign (another unit's name), malformed,
+ * or non-unit legacy file is left alone; other read failures propagate.
+ * @param descriptor - Static identity and shape of the unit.
+ * @param dir - The per-record unit directory (`<root>/<name>`).
+ * @param state - The empty tree state; bootstrapped records are added.
+ */
+async function bootstrapLegacyUnit(descriptor: KvUnitDescriptor, dir: string, state: UnitState): Promise<void> {
+  const legacyPath = join(dirname(dir), `${descriptor.name}.json`)
+  let text: string | undefined
+  try {
+    text = await readFile(legacyPath, 'utf8')
+  } catch (error) {
+    if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+    return
+  }
+  // The legacy document is runtime data: only `unit.name` and the tables map
+  // shape are checked here — the record values are migrated as-is and the
+  // domain layer's schemas judge them.
+  let document: { unit?: { name?: unknown }; tables?: unknown }
+  try {
+    document = JSON.parse(text) as { unit?: { name?: unknown }; tables?: unknown }
+  } catch {
+    return // Malformed legacy file: not ours to interpret or delete.
+  }
+  if (document.unit?.name !== descriptor.name) return
+  const tables = document.tables
+  if (typeof tables !== 'object' || tables === null) return
+  const recordsByTable = tables as Record<string, Record<string, unknown>>
+  for (const [table, records] of Object.entries(recordsByTable)) {
+    const target = state.tables.get(table)
+    if (target === undefined) continue
+    for (const [key, value] of Object.entries(records)) {
+      const path = join(dir, table, `${key}.json`)
+      await mkdir(dirname(path), { recursive: true, mode: 0o700 })
+      await writeAtomic(path, serializeRecord(descriptor.version, value))
+      target.set(key, value)
+    }
+  }
+}
+
+/**
+ * Read one declared table's record documents into `records`.
+ * @returns whether the directory contains any `.json` document path,
+ * independently of key safety, readability, or stored version.
+ */
+async function loadTableRecords(records: Map<string, unknown>, version: number, dir: string): Promise<boolean> {
+  const files = await readdir(dir, { withFileTypes: true })
+  const hasDocuments = files.some(file => file.name.endsWith('.json'))
+  const loaded = await Promise.all(files.map(async (file) => {
+    if (!file.name.endsWith('.json')) return
+    const key = file.name.slice(0, -'.json'.length)
+    if (!SAFE_KEY_RE.test(key)) return
+    const record = await readRecord(join(dir, file.name), version)
+    if (record !== undefined) return [key, record] as const
+  }))
+  for (const record of loaded) {
+    if (record !== undefined) records.set(...record)
+  }
+  return hasDocuments
+}
+
+/** Read one record document; a foreign (unreadable or stale) one reads as absent. */
+async function readRecord(path: string, version: number): Promise<unknown> {
+  try {
+    return parseRecord(await readFile(path, 'utf8'), version)
+  } catch {
+    return undefined
+  }
+}
+
+/**
+ * One opened `per-record`-layout unit. Stateless by design: the directory is
+ * the medium, the domain layer owns the live memory, and each method here is
+ * a single durable file operation. Write ordering belongs to the caller (the
+ * domain layer's write chain), exactly like the `single`-layout unit.
+ */
+export class PerRecordJsonUnit implements KvUnit {
+  private closed = false
+  /** In-flight durable writes; close() drains them before releasing the unit. */
+  private readonly inFlight = new Set<Promise<void>>()
+
+  constructor(
+    private readonly descriptor: KvUnitDescriptor,
+    private readonly dir: string,
+    private readonly onClose: () => void,
+  ) {}
+
+  /** Re-read the tree: the directory is the authoritative state. */
+  async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
+    this.assertOpen()
+    const state = await loadPerRecordState(this.descriptor, this.dir)
+    const tables: Record<string, Record<string, unknown>> = {}
+    for (const [table, records] of state.tables) {
+      tables[table] = Object.fromEntries(records)
+    }
+    return { tables, global: state.global }
+  }
+
+  /** Durably replace one record: its own document, atomically. */
+  async putRecord(table: string, key: string, value: unknown): Promise<void> {
+    this.assertOpen()
+    assertSafeKey(this.descriptor.name, key)
+    await this.tracked(this.writeDocument(join(this.tableDir(table), `${key}.json`), value))
+  }
+
+  /** Durably delete one record. Idempotent: a missing key is a no-op. */
+  async deleteRecord(table: string, key: string): Promise<void> {
+    this.assertOpen()
+    assertSafeKey(this.descriptor.name, key)
+    await this.tracked(rm(join(this.tableDir(table), `${key}.json`), { force: true }))
+  }
+
+  /** Durably replace the global singleton. Only valid when declared. */
+  async setGlobal(value: unknown): Promise<void> {
+    this.assertOpen()
+    if (!this.descriptor.hasGlobal) {
+      throw new Error(`unit '${this.descriptor.name}' does not declare a global slot`)
+    }
+    await this.tracked(this.writeDocument(join(this.dir, 'global.json'), value))
+  }
+
+  /* jscpd:ignore-start -- the two unit classes are standalone; the drain/guard lifecycle mirrors the shared KvUnit contract */
+  /** Drain in-flight writes and release the unit. Idempotent. */
+  async close(): Promise<void> {
+    if (this.closed) {
+      await Promise.allSettled(this.inFlight)
+      return
+    }
+    this.closed = true
+    await Promise.allSettled(this.inFlight)
+    this.onClose()
+  }
+
+  private assertOpen(): void {
+    if (this.closed) {
+      throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`)
+    }
+  }
+  /* jscpd:ignore-end */
+
+  /** Resolve a declared table's directory; an undeclared table is a caller bug and throws. */
+  private tableDir(table: string): string {
+    if (!this.descriptor.tables.includes(table)) {
+      throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`)
+    }
+    return join(this.dir, table)
+  }
+
+  /** Durably replace one document, creating its parent directory. */
+  private writeDocument(path: string, value: unknown): Promise<void> {
+    return (async () => {
+      await mkdir(dirname(path), { recursive: true, mode: 0o700 })
+      await writeAtomic(path, serializeRecord(this.descriptor.version, value))
+    })()
+  }
+
+  /** Track one durable write so close() drains it. */
+  private tracked(write: Promise<void>): Promise<void> {
+    this.inFlight.add(write)
+    // Swallow only on the tracking branch: the caller still awaits `write`
+    // itself, so rejections stay observed exactly once.
+    write.catch(() => {}).finally(() => this.inFlight.delete(write))
+    return write
+  }
+}
+
+/** Reject a record key that would be unsafe as a path segment. */
+function assertSafeKey(unit: string, key: string): void {
+  if (!SAFE_KEY_RE.test(key)) {
+    throw new Error(`unit '${unit}': per-record key '${key}' is not path-safe (must match ${SAFE_KEY_RE})`)
+  }
+}

+ 16 - 9
packages/storage/storage-json/src/unit.ts → packages/storage/storage-json/src/single-unit.ts

@@ -1,13 +1,16 @@
 /**
- * One opened JSON unit. The in-memory state is authoritative; every write
+ * One opened JSON unit in `single` layout: the whole unit is one document at
+ * `<root>/<name>.json`. The in-memory state is authoritative; every write
  * primitive mutates it and republishes the whole file atomically. Writes are
  * NOT queued here — per the backend contract, write ordering belongs to the
  * caller (the domain layer's write chain); this unit only guarantees that
- * each single call publishes a complete, durable file.
- * @module @deepseek-ai/dsh-storage-json/src/unit
+ * each single call publishes a complete, durable file. The `per-record`
+ * layout is a separate unit class in `per-record-unit.ts`.
+ * @module @deepseek-ai/dsh-storage-json/src/single-unit
  */
 
 import { readFile } from 'node:fs/promises'
+import { join } from 'node:path'
 import { StorageError } from '@deepseek-ai/dsh-storage'
 import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
 import { writeAtomic } from './atomic.ts'
@@ -15,17 +18,19 @@ import { parse, serialize } from './format.ts'
 import type { UnitState } from './format.ts'
 
 /**
- * Open (load or lazily create) one unit backed by `path`.
+ * Open (load or lazily create) one `single`-layout unit under `root`: the
+ * unit file is `<root>/<name>.json`.
  * @param descriptor - Static identity and shape of the unit.
- * @param path - Absolute unit file path under the backend root.
+ * @param root - Absolute backend root directory.
  * @param onClose - Backend callback releasing the unit's open-slot.
  * @returns the opened unit.
  */
-export async function openJsonUnit(
+export async function openSingleUnit(
   descriptor: KvUnitDescriptor,
-  path: string,
+  root: string,
   onClose: () => void,
 ): Promise<KvUnit> {
+  const path = join(root, `${descriptor.name}.json`)
   let text: string | undefined
   try {
     text = await readFile(path, 'utf8')
@@ -41,10 +46,10 @@ export async function openJsonUnit(
         tables: new Map(descriptor.tables.map(table => [table, new Map<string, unknown>()])),
       }
       : parse(text, descriptor)
-  return new JsonKvUnit(descriptor, path, state, onClose)
+  return new SingleJsonUnit(descriptor, path, state, onClose)
 }
 
-class JsonKvUnit implements KvUnit {
+class SingleJsonUnit implements KvUnit {
   private closed = false
   /** In-flight publishes; close() drains them before releasing the unit. */
   private readonly inFlight = new Set<Promise<void>>()
@@ -106,6 +111,7 @@ class JsonKvUnit implements KvUnit {
     })
   }
 
+  /* jscpd:ignore-start -- the two unit classes are standalone; the drain/guard lifecycle mirrors the shared KvUnit contract */
   async close(): Promise<void> {
     if (this.closed) {
       await Promise.allSettled(this.inFlight)
@@ -121,6 +127,7 @@ class JsonKvUnit implements KvUnit {
       throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`)
     }
   }
+  /* jscpd:ignore-end */
 
   private records(table: string): Map<string, unknown> {
     const records = this.state.tables.get(table)

+ 194 - 1
packages/storage/storage-json/tests/json-backend.spec.ts

@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
+import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterAll, describe, expect, it } from 'vitest'
@@ -226,3 +226,196 @@ describe('json backend specifics', () => {
     await closing
   })
 })
+
+describe('per-record layout', () => {
+  const descriptor = { name: 'recs', version: 2, layout: 'per-record' as const, tables: ['t'], hasGlobal: true }
+  const recordPath = (root: string, key: string): string => join(root, 'recs', 't', `${key}.json`)
+
+  it('stores one version-stamped document per record and defers materialization', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    // Missing directory = empty unit; nothing materialized on the medium yet.
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await unit.putRecord('t', 'k1', { v: 1 })
+    await unit.putRecord('t', 'k2', { v: 2 })
+    await unit.setGlobal('G')
+    expect(await readFile(recordPath(root, 'k1'), 'utf8'))
+      .toBe(`${JSON.stringify({ version: 2, record: { v: 1 } }, null, 2)}\n`)
+    expect((await readdir(join(root, 'recs', 't'))).sort()).toEqual(['k1.json', 'k2.json'])
+    expect(JSON.parse(await readFile(join(root, 'recs', 'global.json'), 'utf8')))
+      .toEqual({ version: 2, record: 'G' })
+    expect(await unit.loadAll()).toEqual({ tables: { t: { k1: { v: 1 }, k2: { v: 2 } } }, global: 'G' })
+    await backend.close()
+  })
+
+  it('overwrites and deletes one document at a time and persists across reopen', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    await unit.putRecord('t', 'k', { v: 1 })
+    await unit.putRecord('t', 'k', { v: 2 }) // overwrite the same document
+    await unit.deleteRecord('t', 'missing') // idempotent no-op
+    await unit.close()
+    const unit2 = await backend.kv.open(descriptor)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: { k: { v: 2 } } }, global: null })
+    await unit2.deleteRecord('t', 'k')
+    expect(await unit2.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await backend.close()
+  })
+
+  it('rejects unsafe keys and undeclared tables, and enforces the closed guard', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    await expect(unit.putRecord('t', 'a/b', {})).rejects.toThrow(/not path-safe/)
+    await expect(unit.deleteRecord('t', '..')).rejects.toThrow(/not path-safe/)
+    await expect(unit.putRecord('bogus', 'k', {})).rejects.toThrow(/does not declare table/)
+    await unit.close()
+    await expect(unit.putRecord('t', 'k', {})).rejects.toMatchObject({ code: 'closed' })
+    await expect(unit.deleteRecord('t', 'k')).rejects.toMatchObject({ code: 'closed' })
+    await expect(unit.setGlobal('x')).rejects.toMatchObject({ code: 'closed' })
+    await expect(unit.loadAll()).rejects.toMatchObject({ code: 'closed' })
+    await backend.close()
+  })
+
+  it('discards foreign documents (stale version, malformed, non-object, unsafe key) on open', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    await unit.putRecord('t', 'good', { v: 1 })
+    await unit.close()
+    await writeFile(recordPath(root, 'stale'), JSON.stringify({ version: 1, record: { v: 0 } }), 'utf8')
+    await writeFile(recordPath(root, 'broken'), '{oops', 'utf8')
+    await writeFile(recordPath(root, 'scalar'), JSON.stringify(5), 'utf8')
+    await writeFile(recordPath(root, 'unsafe%2Fkey'), JSON.stringify({ version: 2, record: { v: 0 } }), 'utf8')
+    await writeFile(join(root, 'recs', 't', 'not-json.txt'), 'ignored', 'utf8')
+    await writeFile(join(root, 'recs', 'global.json'), JSON.stringify({ version: 1, record: 'old' }), 'utf8')
+    // Stray unit-root entries: an undeclared directory and a non-document file.
+    await mkdir(join(root, 'recs', 'stray-dir'), { recursive: true })
+    await writeFile(join(root, 'recs', 'stray.txt'), 'ignored', 'utf8')
+    const unit2 = await backend.kv.open(descriptor)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: { good: { v: 1 } } }, global: null })
+    await backend.close()
+  })
+
+  it('propagates non-ENOENT read failures and refuses a global slot that is not declared', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    // A file where the unit directory should be: the lazy loadAll readdir
+    // fails with ENOTDIR (opening itself touches nothing on the medium).
+    await writeFile(join(root, 'recs'), 'not a directory', 'utf8')
+    const unit = await backend.kv.open(descriptor)
+    await expect(unit.loadAll()).rejects.toMatchObject({ code: 'ENOTDIR' })
+    await unit.close()
+    const noGlobal = { name: 'plain', version: 1, layout: 'per-record' as const, tables: ['t'], hasGlobal: false }
+    const unit2 = await backend.kv.open(noGlobal)
+    await expect(unit2.setGlobal('x')).rejects.toThrow(/does not declare a global slot/)
+    await backend.close()
+  })
+
+  it('close drains in-flight writes and an unreadable record document reads as absent', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    const big = unit.putRecord('t', 'big', { blob: 'x'.repeat(4 * 1024 * 1024) })
+    await unit.close()
+    await unit.close() // idempotent
+    await expect(big).resolves.toBeUndefined()
+    const onDisk = JSON.parse(await readFile(recordPath(root, 'big'), 'utf8')) as { record: { blob: string } }
+    expect(onDisk.record).toEqual({ blob: 'x'.repeat(4 * 1024 * 1024) })
+    await backend.close()
+  })
+
+  it('reads an unreadable record document as absent (per-record contract)', async () => {
+    const root = await freshRoot()
+    // A directory where the record document should be: readFile fails with
+    // EISDIR on every platform (permission bits are unenforceable on win32).
+    await mkdir(join(root, 'recs', 't', 'locked.json'), { recursive: true })
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await backend.close()
+  })
+
+  it('bootstraps an empty per-record tree from a legacy whole-unit file and preserves it', async () => {
+    const root = await freshRoot()
+    // A legacy single-layout file for the same unit (any older version);
+    // the extra table is not declared and must be skipped.
+    const legacy = JSON.stringify({
+      unit: { name: 'recs', version: 3 },
+      global: null,
+      tables: { t: { old1: { v: 1 }, old2: { v: 2 } }, undeclared: { k: { v: 0 } } },
+    })
+    await writeFile(join(root, 'recs.json'), legacy, 'utf8')
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    expect(await unit.loadAll()).toEqual({ tables: { t: { old1: { v: 1 }, old2: { v: 2 } } }, global: null })
+    await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy)
+    await unit.close()
+    const unit2 = await backend.kv.open(descriptor)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: { old1: { v: 1 }, old2: { v: 2 } } }, global: null })
+    await backend.close()
+  })
+
+  it('ignores the legacy whole-unit file when any new document path exists', async () => {
+    const root = await freshRoot()
+    const legacy = JSON.stringify({
+      unit: { name: 'recs', version: 1 },
+      global: null,
+      tables: { t: { old: { v: 1 } } },
+    })
+    await writeFile(join(root, 'recs.json'), legacy, 'utf8')
+    await mkdir(join(root, 'recs', 't'), { recursive: true })
+    await writeFile(recordPath(root, 'broken'), '{oops', 'utf8')
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(recordPath(root, 'old'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
+    await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy)
+    await backend.close()
+  })
+
+  it('leaves a foreign, shapeless, or malformed legacy file alone', async () => {
+    const root = await freshRoot()
+    await writeFile(join(root, 'recs.json'), JSON.stringify({ unit: { name: 'other', version: 3 }, tables: {} }), 'utf8')
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toContain('other')
+    await unit.close()
+    await backend.close()
+
+    const root2 = await freshRoot()
+    await writeFile(join(root2, 'recs.json'), JSON.stringify({ tables: { t: { k: { v: 1 } } } }), 'utf8')
+    const backend2 = new JsonStorageBackend(root2)
+    const unit2 = await backend2.kv.open(descriptor)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(join(root2, 'recs.json'), 'utf8')).resolves.toContain('tables')
+    await backend2.close()
+
+    const root3 = await freshRoot()
+    // A directory where the legacy file should be: the migration read fails loudly.
+    await mkdir(join(root3, 'recs.json'))
+    const backend3 = new JsonStorageBackend(root3)
+    const unit3 = await backend3.kv.open(descriptor)
+    await expect(unit3.loadAll()).rejects.toMatchObject({ code: 'EISDIR' })
+    await backend3.close()
+
+    const root4 = await freshRoot()
+    await writeFile(join(root4, 'recs.json'), 'not json at all', 'utf8')
+    const backend4 = new JsonStorageBackend(root4)
+    const unit4 = await backend4.kv.open(descriptor)
+    expect(await unit4.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(join(root4, 'recs.json'), 'utf8')).resolves.toBe('not json at all')
+    await backend4.close()
+
+    const root5 = await freshRoot()
+    await writeFile(join(root5, 'recs.json'), JSON.stringify({ unit: { name: 'recs' }, tables: 'not an object' }), 'utf8')
+    const backend5 = new JsonStorageBackend(root5)
+    const unit5 = await backend5.kv.open(descriptor)
+    expect(await unit5.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    await expect(readFile(join(root5, 'recs.json'), 'utf8')).resolves.toContain('not an object')
+    await backend5.close()
+  })
+})

+ 12 - 1
packages/storage/storage/src/backend.ts

@@ -52,6 +52,15 @@ export interface KvUnitDescriptor {
   readonly tables: readonly string[]
   /** Whether this unit carries the global singleton slot. */
   readonly hasGlobal: boolean
+  /**
+   * Medium layout. `single` (the default) keeps the whole unit in one
+   * document; `per-record` keeps each record in its own document, so a unit
+   * whose records are large or sparse never rewrites the rest on one write,
+   * and a version bump discards stale records instead of rejecting the whole
+   * unit. Backends that only serve one layout accept the other's units as
+   * foreign documents.
+   */
+  readonly layout?: 'single' | 'per-record'
 }
 
 /**
@@ -74,7 +83,9 @@ export interface KvUnit {
   /**
    * Upsert one record durably. Overwrite semantics: an existing key is replaced.
    * @param table - Declared table name.
-   * @param key - Record key; any string is safe (keys never reach file paths).
+   * @param key - Record key. In the `per-record` layout a key becomes a path
+   * segment and must match `[a-zA-Z0-9_-]+` (an unsafe key rejects); in the
+   * `single` layout keys stay opaque.
    * @param value - Opaque JSON-serializable record.
    * @returns resolution after durability.
    */

+ 1 - 0
packages/subagent/subagent/package.json

@@ -105,6 +105,7 @@
     "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-storage": "workspace:^",
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
+    "@deepseek-ai/dsh-storage-json": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",

+ 15 - 6
packages/subagent/subagent/tests/list-children.spec.ts

@@ -15,8 +15,12 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache'
 import Storage from '@deepseek-ai/dsh-storage'
-import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
-import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
+import {
+  apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName,
+} from '@deepseek-ai/dsh-storage-json'
+import {
+  apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName,
+} from '@deepseek-ai/dsh-storage-domain'
 import SubagentRuntime, {
   SUBAGENT_DESCRIPTOR_VERSION,
   SubagentError,
@@ -29,7 +33,10 @@ import { TestSessionQuery } from './test-session-query.ts'
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
 const roots: string[] = []
+const projCacheRoots: string[] = []
+
 afterEach(() => {
+  for (const root of projCacheRoots.splice(0)) rmSync(root, { recursive: true, force: true })
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
 })
 
@@ -46,11 +53,13 @@ async function setup(
   await ctx.plugin(AgentLoop, { agents: [] })
   if (options.sessionProjections !== false) await ctx.plugin(SessionProjectionRegistry)
   if (options.projectionCache === true) {
+    const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-projcache-'))
+    projCacheRoots.push(root)
+    // The cache opens its domain through the storage stack; the json backend
+    // lands it under this tmp root.
     await ctx.plugin(Storage)
-    ctx.storage.backend.register('memory', new MemoryStorageBackend(new MemoryMediaPool()))
-    const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
-    ctx.storage.mount('domain', facility)
-    ctx.provide('storageDomain', facility)
+    await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+    await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
     await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
   }
   await ctx.plugin(TestSessionQuery)

+ 44 - 7
packages/test-support/session-snapshot/src/normalize.ts

@@ -6,6 +6,11 @@
  * @module @deepseek-ai/dsh-session-snapshot/normalize
  */
 
+import {
+  decodeStorageRecord,
+  packChunkRuns,
+  type SessionEvent,
+} from '@deepseek-ai/dsh-session'
 import { redactSessionSnapshotIds } from './identity.ts'
 
 const SESSION_ID = '{{sessionId}}'
@@ -367,10 +372,40 @@ export function normalizeSessionLog(
   return records.map(r => JSON.stringify(r)).join('\n') + '\n'
 }
 
+/**
+ * Repack projected body records so persistence flush boundaries do not affect
+ * committed snapshots. Synthetic envelopes exist only while the storage codec
+ * reconstructs and packs the logical event stream; returned rows stay projected.
+ */
+function repackSessionSnapshot(rawLog: string): string {
+  const lines = rawLog.split('\n').filter(line => line.trim().length > 0)
+  const header = lines.shift() as string
+
+  let nextSeq = 0
+  const events = lines.flatMap((line) => {
+    const record = JSON.parse(line) as Record<string, unknown>
+    if (isPackedFixtureRow(record)) {
+      const decoded = decodeStorageRecord({ ...record, seq0: nextSeq, time0: 0 })
+      nextSeq += decoded.length
+      return decoded
+    }
+    const event = { ...record, seq: nextSeq, time: 0 } as SessionEvent
+    nextSeq += 1
+    return [event]
+  })
+  const body = packChunkRuns(events).map((stored) => {
+    const projected = { ...stored } as Record<string, unknown>
+    omitFixtureEnvelope(projected)
+    return JSON.stringify(projected)
+  })
+  return [header, ...body, ''].join('\n')
+}
+
 /**
  * Normalize and project persisted session JSONL for a committed fixture.
  * This composes ordinary log normalization with request-header scrubbing and
- * persistence-envelope projection.
+ * persistence-envelope projection, then packs the logical event stream into a
+ * canonical layout independent of persistence flush boundaries.
  *
  * @param rawLog - persisted or already-projected session JSONL.
  * @param ctx - the run's volatile values to scrub.
@@ -382,7 +417,7 @@ export function normalizeSessionSnapshot(
   ctx: NormalizeContext,
   options: NormalizeOptions = {},
 ): string {
-  return scrubSessionSnapshot(normalizeSessionLog(rawLog, ctx, options))
+  return repackSessionSnapshot(scrubSessionSnapshot(normalizeSessionLog(rawLog, ctx, options)))
 }
 
 /**
@@ -397,11 +432,13 @@ export function normalizeSessionSnapshots(
   ctx: NormalizeContext,
   options: Omit<NormalizeOptions, 'identityMode'> = {},
 ): string[] {
-  return redactSessionSnapshotIds(rawLogs).map(log => scrubSessionSnapshot(normalizeSessionLog(
-    log,
-    { ...ctx, sessionIds: [] },
-    { ...options, identityMode: 'preserve' },
-  )))
+  return redactSessionSnapshotIds(rawLogs).map(log => repackSessionSnapshot(
+    scrubSessionSnapshot(normalizeSessionLog(
+      log,
+      { ...ctx, sessionIds: [] },
+      { ...options, identityMode: 'preserve' },
+    )),
+  ))
 }
 
 /**

+ 47 - 2
packages/test-support/session-snapshot/tests/normalize.spec.ts

@@ -4,6 +4,7 @@ import {
   extractSnapshotSpillPaths,
   normalizeSessionLog,
   normalizeSessionSnapshot,
+  normalizeSessionSnapshots,
   normalizeStdout,
   scrubRequestHeaders,
   scrubSessionSnapshot,
@@ -496,10 +497,54 @@ describe('normalizeSessionSnapshot', () => {
       JSON.stringify({ type: 'session', version: 0 }),
       JSON.stringify({
         type: 'text-chunks',
-        data: { turn: 1, step: 1, index: 0, dt: [9], texts: ['a', 'b'] },
+        data: { turn: 1, step: 1, index: 0, dt: [9, 8], texts: ['a', 'b', 'c'] },
       }),
     ].join('\n') + '\n'
-    expect(normalizeSessionSnapshot(raw, ctx)).toContain('"dt":[0]')
+    expect(normalizeSessionSnapshot(raw, ctx)).toContain('"dt":[0,0]')
+  })
+
+  it('re-packs adjacent chunk runs split by persistence flushes', () => {
+    const raw = [
+      JSON.stringify({ type: 'session', version: 0 }),
+      JSON.stringify({
+        type: 'text-chunks',
+        data: { turn: 1, step: 1, index: 0, dt: [4, 5], texts: ['a', 'b', 'c'] },
+      }),
+      JSON.stringify({
+        type: 'text-chunks',
+        data: { turn: 1, step: 1, index: 0, dt: [6, 7], texts: ['d', 'e', 'f'] },
+      }),
+    ].join('\n') + '\n'
+    expect(normalizeSessionSnapshot(raw, ctx)).toBe([
+      JSON.stringify({ type: 'session', version: 0 }),
+      JSON.stringify({
+        type: 'text-chunks',
+        data: { turn: 1, step: 1, index: 0, dt: [0, 0, 0, 0, 0], texts: ['a', 'b', 'c', 'd', 'e', 'f'] },
+      }),
+      '',
+    ].join('\n'))
+  })
+
+  it('re-packs multi-session fixtures after relationship-preserving id redaction', () => {
+    const raw = [
+      JSON.stringify({ type: 'session', version: 0 }),
+      JSON.stringify({
+        type: 'reasoning-chunks',
+        data: { turn: 1, step: 1, index: 0, dt: [1, 2], texts: ['a', 'b', 'c'] },
+      }),
+      JSON.stringify({
+        type: 'reasoning-chunks',
+        data: { turn: 1, step: 1, index: 0, dt: [3, 4], texts: ['d', 'e', 'f'] },
+      }),
+    ].join('\n') + '\n'
+    expect(normalizeSessionSnapshots([raw], ctx)).toEqual([[
+      JSON.stringify({ type: 'session', version: 0 }),
+      JSON.stringify({
+        type: 'reasoning-chunks',
+        data: { turn: 1, step: 1, index: 0, dt: [0, 0, 0, 0, 0], texts: ['a', 'b', 'c', 'd', 'e', 'f'] },
+      }),
+      '',
+    ].join('\n')])
   })
 
   it('rejects headerless input', () => {

+ 18 - 15
pnpm-lock.yaml

@@ -1087,6 +1087,9 @@ importers:
       '@deepseek-ai/dsh-session-projection':
         specifier: workspace:^
         version: link:../../session/session-projection
+      '@deepseek-ai/dsh-session-projection-cache':
+        specifier: workspace:^
+        version: link:../../session/session-projection-cache
       '@deepseek-ai/dsh-session-query-sqlite':
         specifier: workspace:^
         version: link:../../session-query/session-query-sqlite
@@ -1120,6 +1123,15 @@ importers:
       '@deepseek-ai/dsh-spill-policy':
         specifier: workspace:^
         version: link:../../spill/spill-policy
+      '@deepseek-ai/dsh-storage':
+        specifier: workspace:^
+        version: link:../../storage/storage
+      '@deepseek-ai/dsh-storage-domain':
+        specifier: workspace:^
+        version: link:../../storage/storage-domain
+      '@deepseek-ai/dsh-storage-json':
+        specifier: workspace:^
+        version: link:../../storage/storage-json
       '@deepseek-ai/dsh-subagent':
         specifier: workspace:^
         version: link:../../subagent/subagent
@@ -1529,24 +1541,12 @@ importers:
       '@deepseek-ai/dsh-session-log-export':
         specifier: workspace:^
         version: link:../../session-query/session-log-export
-      '@deepseek-ai/dsh-session-projection-cache':
-        specifier: workspace:^
-        version: link:../../session/session-projection-cache
       '@deepseek-ai/dsh-session-reference':
         specifier: workspace:^
         version: link:../../context/session-reference
       '@deepseek-ai/dsh-session-stats':
         specifier: workspace:^
         version: link:../../session/session-stats
-      '@deepseek-ai/dsh-storage':
-        specifier: workspace:^
-        version: link:../../storage/storage
-      '@deepseek-ai/dsh-storage-domain':
-        specifier: workspace:^
-        version: link:../../storage/storage-domain
-      '@deepseek-ai/dsh-storage-json':
-        specifier: workspace:^
-        version: link:../../storage/storage-json
       '@deepseek-ai/dsh-subprocess':
         specifier: workspace:^
         version: link:../../subprocess/subprocess
@@ -7122,9 +7122,6 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
-      '@deepseek-ai/dsh-session-persistence':
-        specifier: workspace:^
-        version: link:../session-persistence
       '@deepseek-ai/dsh-session-projection':
         specifier: workspace:^
         version: link:../session-projection
@@ -7134,6 +7131,9 @@ importers:
       '@deepseek-ai/dsh-storage-domain':
         specifier: workspace:^
         version: link:../../storage/storage-domain
+      '@deepseek-ai/dsh-storage-json':
+        specifier: workspace:^
+        version: link:../../storage/storage-json
 
   packages/session/session-stats:
     dependencies:
@@ -8103,6 +8103,9 @@ importers:
       '@deepseek-ai/dsh-storage-domain':
         specifier: workspace:^
         version: link:../../storage/storage-domain
+      '@deepseek-ai/dsh-storage-json':
+        specifier: workspace:^
+        version: link:../../storage/storage-json
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt

+ 1 - 0
scripts/run-gates.spec.ts

@@ -167,6 +167,7 @@ describe('gate graph validation', () => {
     const byId = new Map(complete.map(subject => [subject.id, subject]))
 
     expect(byId.get('coverage')?.allowFailure).not.toBe(true)
+    expect(byId.get('coverage')?.needs).toContain('build')
     expect(byId.get('coverage-exempt-heavy')?.allowFailure).not.toBe(true)
     expect(byId.get('coverage')?.needs).toContain('build')
     expect(byId.get('coverage-exempt-heavy')?.needs).toContain('build')

+ 1 - 1
snapshots/sdk/subagent-mixed/session.jsonl

@@ -54,7 +54,7 @@
 {"type":"step/end","data":{"turn":2,"step":2}}
 {"type":"step/start","data":{"turn":2,"step":3}}
 {"type":"assistant/chunk","data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
-{"type":"reasoning-chunks","data":{"turn":2,"step":3,"index":0,"dt":[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["Both"," sub","agents"," returned",":\n","1","."," First"," (","fresh"," child","):"," \"","AL","P","HA","\"\n","2","."," Second"," (","fork","ed"," child","):"," \"","SA","FF","RON","\""," -"," correctly"," inherited"," the"," conversation"," context"," where"," I"," was"," asked"," to"," remember"," the"," cod","ew","ord"," \"","SA","FF","RON","\".\n\n","Now"," I"," reply"," with"," \"","PAR","ENT","_D","ONE","\""," as"," instructed","."]}}
+{"type":"reasoning-chunks","data":{"turn":2,"step":3,"index":0,"dt":[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,-1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["Both"," sub","agents"," returned",":\n","1","."," First"," (","fresh"," child","):"," \"","AL","P","HA","\"\n","2","."," Second"," (","fork","ed"," child","):"," \"","SA","FF","RON","\""," -"," correctly"," inherited"," the"," conversation"," context"," where"," I"," was"," asked"," to"," remember"," the"," cod","ew","ord"," \"","SA","FF","RON","\".\n\n","Now"," I"," reply"," with"," \"","PAR","ENT","_D","ONE","\""," as"," instructed","."]}}
 {"type":"assistant/chunk","data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
 {"type":"text-chunks","data":{"turn":2,"step":3,"index":1,"dt":[0,0,0],"texts":["PAR","ENT","_D","ONE"]}}
 {"type":"assistant/chunk","data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."}}}}

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.