Jelajahi Sumber

feat(session-projection-cache): store one projection_cache.json per session

Replace the single global session_projcache domain with a per-session
cache file inside the session's own persistence directory, resolved
through sessionPersistence.locate(meta) — the persistence backend owns
the session-directory layout, the cache service keeps every checkpoint
and cold-read responsibility.

- cachedSnapshot(meta) becomes async (one file read per session);
  coldSnapshot takes the session header so it can locate the file, with
  the stored log header remaining the identity witness.
- Backends without a per-session directory (sqlite) disable the durable
  cache: writes no-op and cold reads fall to the full-log rung. An
  obsolete global cache is never read — derived data refolds on first
  cold read (no migration).
- writeAtomic is exported from dsh-storage-json as the shared atomic
  whole-file replace primitive; api-proxy listing and subagent cold
  reads await the now-async cachedSnapshot.
- READMEs and a new Agent Note document the per-session medium.
_Kerman 1 bulan lalu
induk
melakukan
cdb4cc3c68
25 mengubah file dengan 430 tambahan dan 304 penghapusan
  1. 6 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml
  2. 28 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
  3. 28 0
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md
  4. 5 4
      apps/web/tests/agent-preset-selection.e2e.ts
  5. 9 7
      apps/web/tests/subagent-conversation.e2e.ts
  6. 2 2
      docs/subsystems/session-projection.i18n.yaml
  7. 17 15
      docs/subsystems/session-projection.md
  8. 17 15
      docs/subsystems/session-projection.zh.md
  9. 6 6
      packages/extensions/tool-cordis/src/api-catalog.ts
  10. 10 6
      packages/host/apiproxy/src/api-proxy.ts
  11. 1 1
      packages/host/apiproxy/tests/api-proxy-cold.spec.ts
  12. 1 1
      packages/host/apiproxy/tests/api-proxy-projections.spec.ts
  13. 2 2
      packages/session/session-projection-cache/README.i18n.yaml
  14. 8 7
      packages/session/session-projection-cache/README.md
  15. 4 3
      packages/session/session-projection-cache/README.zh.md
  16. 2 3
      packages/session/session-projection-cache/package.json
  17. 103 66
      packages/session/session-projection-cache/src/index.ts
  18. 2 2
      packages/session/session-projection-cache/src/invariant.ts
  19. 8 23
      packages/session/session-projection-cache/src/spec.ts
  20. 158 126
      packages/session/session-projection-cache/tests/cache.spec.ts
  21. 1 4
      packages/session/session-projection-cache/tsconfig.json
  22. 1 0
      packages/storage/storage-json/src/index.ts
  23. 1 1
      packages/subagent/subagent/src/list-children.ts
  24. 7 7
      packages/subagent/subagent/tests/list-children.spec.ts
  25. 3 3
      pnpm-lock.yaml

+ 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: bdc08a49b9c68f8599d349df0a34b942c5ad2c7c
+2026-08-19-projection-cache-per-session-files.zh.md: 9ae940c17dfb124ac90e984c00083e69c8fc4103

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

@@ -0,0 +1,28 @@
+# 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 becomes one `projection_cache.json` per session, stored inside the session's own persistence directory. The location comes from the persistence seam — `sessionPersistence.locate(meta)` — so the persistence backend owns the session-directory layout (the jsonl backend places the file beside the session log); the cache service never imports a backend's path helpers. The cache service keeps every other responsibility: checkpoint fold, write policy (turn/end + disposal mandatory, count/interval throttle), fail-soft durability, and the cold-read ladder.
+
+Reading a cache row is now one file read, so `cachedSnapshot(meta)` is async; `coldSnapshot` takes the session header (it needs the header to locate the file — the stored log's header remains the identity witness). A persistence backend without a per-session directory (e.g. sqlite) disables the durable cache: writes no-op and cold reads fall to the full-log rung.
+
+## Consequences
+
+- Per-session write isolation: each throttled write replaces only that session's small file, removing the global write amplification.
+- Listing pays N small file reads instead of one big load; a session without a cache file simply lacks the projection column.
+- No migration: the cache is derived data, never an authority. An obsolete global cache (or any earlier format) is never read — the first cold read refolds from the log and writes the current format.
+- The cache file is bound to the same log lifecycle as before: the stored `{createdAt, cwd}` identity guards against a recreated id or a swapped store.
+
+## Alternatives considered
+
+- **Keep the global sessions table.** Preserves one-load listing and a synchronous `cachedSnapshot`, but keeps the global write amplification and single-file blast radius that motivated the change.
+- **One storage-domain unit per session in the storage root** (flat `session_projcache_<id>.json`). Rejected: unit names must match `[a-z0-9_]` (session ids cannot), and the files would sit outside the session's own directory, scattering the storage root instead of living beside the log.
+- **Replicate the session-directory layout inside the cache.** Rejected: the persistence backend already owns that layout through `locate`; duplicating the path helpers couples the cache to a backend's internals.

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

@@ -0,0 +1,28 @@
+# Agent Note:投影缓存改为每会话文件
+
+Status: implemented
+
+[English](2026-08-19-projection-cache-per-session-files.md) | 中文
+
+## Problem
+
+持久投影缓存曾是单个全局 `session_projcache.json`——存储根目录下一个文件里的 `sessions` 表。每次节流检查点都会重写包含所有会话行的整个文件,写放大随会话数量增长;且一个畸形文件会让整个缓存一起失效。
+
+## Decision
+
+缓存改为每会话一个 `projection_cache.json`,存放在该会话自己的持久化目录内。位置来自持久化 seam——`sessionPersistence.locate(meta)`——因此会话目录布局由持久化后端所有(jsonl 后端将其放在会话日志旁);缓存服务绝不 import 后端的路径 helper。缓存服务保留其余全部职责:检查点折叠、写策略(turn/end + dispose 强制点、count/interval 节流)、fail-soft 持久化与冷读阶梯。
+
+读取缓存行现在是一次文件读取,因此 `cachedSnapshot(meta)` 变为异步;`coldSnapshot` 改为接收会话 header(定位文件需要 header——存储日志的 header 仍是身份见证)。没有每会话目录的持久化后端(如 sqlite)会禁用持久缓存:写入变为 no-op,冷读落到全量日志那一级。
+
+## Consequences
+
+- 每会话写入隔离:每次节流写入只替换该会话的小文件,消除全局写放大。
+- 列表读取从一次大加载变为 N 次小文件读取;没有缓存文件的会话只是缺少投影列。
+- 无需迁移:缓存是派生数据,绝非权威。过时的全局缓存(或任何更早格式)从不被读取——首次冷读从日志重折叠并写出当前格式。
+- 缓存文件仍绑定同一日志生命周期:存储的 `{createdAt, cwd}` 身份防止被重建的 id 或替换的存储误导。
+
+## Alternatives considered
+
+- **保留全局 sessions 表。** 保留一次加载式列表与同步 `cachedSnapshot`,但保留了促成此改动的全局写放大与单文件爆炸半径。
+- **存储根目录下每会话一个 storage-domain unit**(扁平 `session_projcache_<id>.json`)。未采用:unit 名必须匹配 `[a-z0-9_]`(会话 id 做不到),且文件会落在会话目录之外,散落在存储根目录而不是日志旁。
+- **在缓存内复刻会话目录布局。** 未采用:持久化后端已通过 `locate` 拥有该布局;复刻路径 helper 会把缓存耦合到后端的内部实现。

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

@@ -17,7 +17,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 {
@@ -91,7 +91,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,
@@ -100,7 +100,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',
@@ -133,7 +134,7 @@ async function seedSubagent(scaffold: WebScaffold, parentId: SessionId): Promise
       data: { turn: 1, reason: { kind: 'completed' } },
     },
   ] as SessionEvent[])
-  await scaffold.ctx.sessionProjectionCache.coldSnapshot(childId)
+  await scaffold.ctx.sessionProjectionCache.coldSnapshot(header)
 }
 
 /**

+ 9 - 7
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'
@@ -113,7 +113,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,
@@ -121,7 +121,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       parentSession: parent.id,
       origin: 'subagent',
       delegationDepth: 1,
-    })
+    }
+    await scaffold.ctx.sessionPersistence.create(oneShotHeader)
     await scaffold.ctx.sessionPersistence.append(oneShotId, [
       {
         type: 'turn/start',
@@ -154,10 +155,10 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
         data: { turn: 1, reason: { kind: 'completed' } },
       },
     ] as SessionEvent[])
-    await scaffold.ctx.sessionProjectionCache.coldSnapshot(oneShotId)
+    await scaffold.ctx.sessionProjectionCache.coldSnapshot(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,
@@ -165,7 +166,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
       parentSession: childId,
       origin: 'subagent',
       delegationDepth: 2,
-    })
+    }
+    await scaffold.ctx.sessionPersistence.create(grandchildHeader)
     await scaffold.ctx.sessionPersistence.append(grandchildId, [
       {
         type: 'turn/start',
@@ -198,7 +200,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
         data: { turn: 1, reason: { kind: 'completed' } },
       },
     ] as SessionEvent[])
-    await scaffold.ctx.sessionProjectionCache.coldSnapshot(grandchildId)
+    await scaffold.ctx.sessionProjectionCache.coldSnapshot(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/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: 50ddc8ddb80a2df1a1c8f54133da134f571ef1ce
-session-projection.zh.md: 06b9f71c263805572ef408d04f9c43021dc13597
+session-projection.md: 66543b6bfff666435acb8c00dd2f87d5fb0bccb4
+session-projection.zh.md: 4d99c208a8c54424655ec0b4238dbed37c75603b

+ 17 - 15
docs/subsystems/session-projection.md

@@ -114,28 +114,29 @@ 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. 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 file, 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. A persistence backend without a per-session directory (e.g. sqlite) disables the durable cache: writes no-op, cold reads fall to the full-log rung.
 
 ```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
- * 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.
+ * The 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, {@link coldSnapshot}) supersede these values
+ * whenever a session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
  *   `undefined` when no usable row exists for this lifecycle.
  */
-cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
+async cachedSnapshot(meta: SessionHeader): Promise<ProjectionSnapshot | undefined>
 
 /**
  * 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 cache
+ * file is replaced. 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.
  */
@@ -149,16 +150,17 @@ async write(session: Session): Promise<void>
  * (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 meta - the persisted session whose projections are read (locates
+ *   the cache file and witnesses the stored log identity).
  * @param signal - optional cancellation for the persistence reads.
  * @returns the snapshot cut at the stored log end.
  */
-async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
+async coldSnapshot(meta: SessionHeader, signal?: AbortSignal): Promise<ProjectionSnapshot>
 ```
 
-Types: [Session](session.md) · [SessionHeader](persistence.md) · [SessionId](core.md)
+Types: [Session](session.md) · [SessionHeader](persistence.md)
 
-Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:78`](../../packages/session/session-projection-cache/src/index.ts)
 
 <a id="ctxsessionprojections--sessionprojectionregistry"></a>
 

+ 17 - 15
docs/subsystems/session-projection.zh.md

@@ -114,28 +114,29 @@ 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. 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 file, 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. A persistence backend without a per-session directory (e.g. sqlite) disables the durable cache: writes no-op, cold reads fall to the full-log rung.
 
 ```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
- * 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.
+ * The 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, {@link coldSnapshot}) supersede these values
+ * whenever a session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
  *   `undefined` when no usable row exists for this lifecycle.
  */
-cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
+async cachedSnapshot(meta: SessionHeader): Promise<ProjectionSnapshot | undefined>
 
 /**
  * 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 cache
+ * file is replaced. 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.
  */
@@ -149,16 +150,17 @@ async write(session: Session): Promise<void>
  * (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 meta - the persisted session whose projections are read (locates
+ *   the cache file and witnesses the stored log identity).
  * @param signal - optional cancellation for the persistence reads.
  * @returns the snapshot cut at the stored log end.
  */
-async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>
+async coldSnapshot(meta: SessionHeader, signal?: AbortSignal): Promise<ProjectionSnapshot>
 ```
 
-Types: [Session](session.md) · [SessionHeader](persistence.md) · [SessionId](core.md)
+Types: [Session](session.md) · [SessionHeader](persistence.md)
 
-Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:78`](../../packages/session/session-projection-cache/src/index.ts)
 
 <a id="ctxsessionprojections--sessionprojectionregistry"></a>
 

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

@@ -1085,24 +1085,24 @@ 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. 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 file, 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. A persistence backend without a per-session directory (e.g. sqlite) disables the durable cache: writes no-op, cold reads fall to the full-log rung.',
     methods: [
       {
-        signature: 'cachedSnapshot(meta: SessionHeader): 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.',
+        signature: 'async cachedSnapshot(meta: SessionHeader): Promise<ProjectionSnapshot | undefined>',
+        description: 'The 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.',
         parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }],
         returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.',
       },
       {
         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 cache file is replaced. 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>',
+        signature: 'async coldSnapshot(meta: SessionHeader, 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.' }],
+        parameters: [{ name: 'meta', description: 'the persisted session whose projections are read (locates the cache file and witnesses the stored log identity).' }, { name: 'signal', description: 'optional cancellation for the persistence reads.' }],
         returns: 'the snapshot cut at the stored log end.',
       },
     ],

+ 10 - 6
packages/host/apiproxy/src/api-proxy.ts

@@ -800,11 +800,15 @@ function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock
  * empty value set — yields an absent block: a listing without projections
  * is degraded, never broken.
  */
-function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
+async function listProjectionsFor(
+  ctx: Context,
+  meta: SessionHeader,
+  session: Session | undefined,
+): Promise<SessionProjectionsBlock | undefined> {
   try {
     const block = session !== undefined
       ? ctx.get('sessionProjections')?.snapshot(session)
-      : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
+      : await ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
     return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
   } catch (error) {
     ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
@@ -1670,15 +1674,15 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
    */
   async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
     signal?.throwIfAborted()
-    const summarizeAttached = (session: Session): SessionSummary => {
+    const summarizeAttached = async (session: Session): Promise<SessionSummary> => {
       const agent = ctx.agents.get(session.id)
-      const projections = listProjectionsFor(ctx, session.header, session)
+      const projections = await listProjectionsFor(ctx, session.header, session)
       return {
         ...summarize(session, agent?.status === 'running'),
         ...projections === undefined ? {} : { projections },
       }
     }
-    const items = ctx.sessions.list().map(summarizeAttached)
+    const items = await Promise.all(ctx.sessions.list().map(summarizeAttached))
     signal?.throwIfAborted()
     const attached = new Set(items.map(item => item.sessionId))
     const persistence = ctx.get('sessionPersistence')
@@ -1693,7 +1697,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           batch.map(async (meta) => {
             // Projection hints remain optional. Blank verification may read
             // this Session's artifact only when it passes the configured size check.
-            const projections = listProjectionsFor(ctx, meta, undefined)
+            const projections = await listProjectionsFor(ctx, meta, undefined)
             const summary = await summarizeCold(
               ctx,
               persistence,

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-cold.spec.ts

@@ -91,7 +91,7 @@ describe('sessions.list cold merge', () => {
       readFrom,
     } as never)
     ctx.provide('sessionProjectionCache', {
-      cachedSnapshot: (meta: SessionHeader) => {
+      cachedSnapshot: async (meta: SessionHeader) => {
         if (meta.id === sid('small-blank')) {
           return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } }
         }

+ 1 - 1
packages/host/apiproxy/tests/api-proxy-projections.spec.ts

@@ -274,7 +274,7 @@ describe('session.list projections column', () => {
     } as never)
     ctx.provide('sessionProjectionCache', {
       // The carrier hands the listed header through as the identity witness.
-      cachedSnapshot: (meta: { id: unknown; createdAt: number }) =>
+      cachedSnapshot: async (meta: { id: unknown; createdAt: number }) =>
         (meta.id === coldId && meta.createdAt === 5
           ? { asOfSeq: 7, values: { 'test/last-user': { text: 'cached' } } }
           : undefined),

+ 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: 9760cf3cf8382bda6866e679f1d884990a09f0cf
+README.md: 8f09af893c6ab2f2d13436fdfd979702e675592d
+README.zh.md: 5cd60c7fe2ede6459e99990a3f1e657dc5db807a

+ 8 - 7
packages/session/session-projection-cache/README.md

@@ -2,16 +2,17 @@
 
 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 `projection_cache.json` per session inside the session's own persistence directory (resolved through `sessionPersistence.locate(meta)` — the jsonl backend places it beside the session log). 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.
 - **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.
-- **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.
+- **A row must pass the live unit's `stateSchema`.** A malformed row is omitted from the cached view and rejected by restore so the cold-read ladder refolds it from the log.
+- **Whole-record writes.** Each write atomically replaces the session's cache file (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 record, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
+- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache file lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
+- **Per-session files, no global medium.** A persistence backend without a per-session directory (e.g. sqlite) disables the durable cache: writes no-op and cold reads fall to the full-log rung. An obsolete cache (any earlier format) is never read — the first cold read refolds from the log and writes the current format.
 
 ## Write policy
 
@@ -28,9 +29,9 @@ Both `Config` fields are required (no defaults): flush cadence is a deployment c
 
 ## 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.
+One file read per session: 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, missing file, or no usable rows); the api-proxy list carrier turns that into an absent column.
 
-## Cold read (`coldSnapshot(id, signal?)`)
+## Cold read (`coldSnapshot(meta, 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`.
 

+ 4 - 3
packages/session/session-projection-cache/README.zh.md

@@ -2,15 +2,16 @@
 
 [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.md)(persisted projection cache 一节)。
+持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,每会话一个 `projection_cache.json`,位于该会话自己的持久化目录内(经 `sessionPersistence.locate(meta)` 解析——jsonl 后端将其放在会话日志旁)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。
 
 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
 
 - **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
 - **`ver` 与当前运行单元的 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
-- **存储行必须通过当前单元的 `stateSchema`。** 畸形行从零 I/O view 中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。
-- **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。
+- **存储行必须通过当前单元的 `stateSchema`。** 畸形行从缓存视图中省略,并被 restore 拒绝,使冷读阶梯从日志重新折叠。
+- **整记录写入。** 每次写入原子替换该会话的缓存文件(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会显式失败并报错。
 - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
+- **每会话文件,无全局介质。** 没有每会话目录的持久化后端(如 sqlite)会禁用持久缓存:写入变为 no-op,冷读落到全量日志那一级。过时的缓存(任何更早格式)从不被读取——首次冷读从日志重折叠并写出当前格式。
 - **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。
 
 ## 写策略

+ 2 - 3
packages/session/session-projection-cache/package.json

@@ -33,6 +33,7 @@
   "license": "MIT",
   "dependencies": {
     "@deepseek-ai/schemastery": "workspace:^",
+    "@deepseek-ai/dsh-storage-json": "workspace:^",
     "zod": "^4.4.3"
   },
   "peerDependencies": {
@@ -40,7 +41,6 @@
     "@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:^"
   },
   "devDependencies": {
@@ -48,8 +48,7 @@
     "@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/dsh-storage-json": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   }
 }

+ 103 - 66
packages/session/session-projection-cache/src/index.ts

@@ -1,32 +1,37 @@
 /**
  * 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 `projection_cache.json`
+ * per session inside the session's own persistence directory (resolved via
+ * `sessionPersistence.locate(meta)` — the jsonl backend places it beside
+ * the session log). 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
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
+import { readFile, mkdir } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
 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 } from '@deepseek-ai/dsh-session-projection'
-import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
-import { projectionCacheDomainSpec } from './spec.ts'
+import { writeAtomic } from '@deepseek-ai/dsh-storage-json'
+import { checkpointRecord } from './spec.ts'
 import type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
 
-export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts'
+export { checkpointIdentity, checkpointRecord, checkpointRow } from './spec.ts'
 export type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
 
+/** Cache file name inside each session's own persistence directory. */
+const CACHE_FILE_NAME = 'projection_cache.json'
+
 declare module '@deepseek-ai/cordis' {
   interface Context {
     sessionProjectionCache: SessionProjectionCache
@@ -60,64 +65,86 @@ 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.
+ * The persisted projection cache service. 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 file, 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. A persistence backend without a per-session
+ * directory (e.g. sqlite) disables the durable cache: writes no-op, cold
+ * reads fall to the full-log rung.
  */
 export class SessionProjectionCache extends Service {
-  static inject = ['storageDomain', 'sessionProjections', 'sessionPersistence', 'sessions']
+  static inject = ['sessionProjections', 'sessionPersistence', 'sessions']
 
   static Config: z<Config> = Config
 
-  private table?: KvTable<SessionId, CheckpointRecord>
   private readonly dirty = new Map<Session, DirtyState>()
 
   constructor(ctx: Context, public config: Config) {
     super(ctx, 'sessionProjectionCache')
   }
 
-  /** Open the domain and install the write-behind listeners. */
+  /** Install the write-behind listeners. */
   protected async [Service.init](): Promise<void> {
-    const domain = await this.ctx.storageDomain.open(projectionCacheDomainSpec)
-    this.ctx.effect(() => () => domain.close(), 'sessionProjectionCache.domainClose')
-    this.table = domain.table('sessions')
     this.installWritePath()
   }
 
   /**
-   * The stored record for one session, accepted only when its bound log
-   * 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.
-   * @param id - the session whose record is read.
+   * Resolve one session's cache file path, or `undefined` when the
+   * persistence backend owns no per-session directory. The file sits beside
+   * the backend's session artifact (the jsonl log), derived from
+   * `sessionPersistence.locate(meta)` — the persistence backend is the sole
+   * owner of the session-directory layout.
+   * @param meta - the session header naming the persistence location.
+   * @returns the absolute cache-file path, or `undefined` for backends
+   *   without a per-session artifact.
+   */
+  private cachePathFor(meta: SessionHeader): string | undefined {
+    const location = this.ctx.sessionPersistence.locate(meta)
+    if (location === undefined) return undefined
+    return join(dirname(location.path), CACHE_FILE_NAME)
+  }
+
+  /**
+   * Read and validate one session's stored record, accepted only when its
+   * bound log 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. Fail-soft — an absent, unreadable, or malformed file
+   * reads as "no cache row".
+   * @param meta - the session header locating the file.
    * @param expected - the log identity the caller holds (live or stored header).
-   * @returns the identity-matching record, or `undefined` (absent or unrelated).
+   * @returns the identity-matching record, or `undefined`.
    */
-  private recordFor(id: SessionId, expected: CheckpointIdentity): CheckpointRecord | undefined {
-    const record = this.requireTable().get(id)
-    if (record === undefined) return undefined
-    return identityMatches(record.identity, expected) ? record : undefined
+  private async recordFor(meta: SessionHeader, expected: CheckpointIdentity): Promise<CheckpointRecord | undefined> {
+    const path = this.cachePathFor(meta)
+    if (path === undefined) return undefined
+    try {
+      const record = checkpointRecord.parse(JSON.parse(await readFile(path, 'utf8')))
+      return identityMatches(record.identity, expected) ? record : undefined
+    } catch {
+      // Absent, unreadable, malformed, or identity-mismatched — all read as
+      // "no cache row"; the cold-read ladder refolds from the log.
+      return undefined
+    }
   }
 
   /**
-   * 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, {@link coldSnapshot}) supersede these
-   * values whenever a session is actually opened.
+   * The 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, {@link coldSnapshot}) supersede these values
+   * whenever a session is actually opened.
    * @param meta - the listed session's header (identity witness; no log read).
    * @returns the cut (`asOfSeq` = lowest served-row watermark), or
    *   `undefined` when no usable row exists for this lifecycle.
    */
-  cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined {
-    const record = this.recordFor(meta.id, identityOf(meta))
+  async cachedSnapshot(meta: SessionHeader): Promise<ProjectionSnapshot | undefined> {
+    const record = await this.recordFor(meta, identityOf(meta))
     if (record === undefined) return undefined
     const values = this.ctx.sessionProjections.viewCheckpoint(record.rows)
     const keys = Object.keys(values)
@@ -132,8 +159,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 cache
+   * file is replaced. 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.
    */
@@ -142,13 +170,16 @@ export class SessionProjectionCache extends Service {
     this.markClean(session)
     // Durability barrier: the checkpoint cut was taken above, so flushing
     // AFTER it guarantees every event inside the cut is durably logged
-    // before the cache row lands — a crash can leave the cache behind the
+    // before the cache file lands — a crash can leave the cache behind the
     // log (longer tail replay) but never ahead of it (phantom values folded
     // from events no stored log contains). At detach the store entry is
     // already gone; persistence's own retirement drain covers that path and
     // any residual overreach is caught by the cold read's anchored floor.
     if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session)
-    await this.put(session.id, identityOf(session.header), rows)
+    const path = this.cachePathFor(session.header)
+    // A backend without a per-session directory (sqlite) persists no cache.
+    if (path === undefined) return
+    await this.put(path, identityOf(session.header), rows)
   }
 
   /**
@@ -159,12 +190,13 @@ export class SessionProjectionCache extends Service {
    * (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 meta - the persisted session whose projections are read (locates
+   *   the cache file and witnesses the stored log identity).
    * @param signal - optional cancellation for the persistence reads.
    * @returns the snapshot cut at the stored log end.
    */
-  async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
-    const record = this.requireTable().get(id)
+  async coldSnapshot(meta: SessionHeader, signal?: AbortSignal): Promise<ProjectionSnapshot> {
+    const record = await this.recordFor(meta, identityOf(meta))
     const cached = record?.rows ?? {}
     const floor = this.ctx.sessionProjections.restoreFloor(cached)
     const persistence = this.ctx.sessionPersistence
@@ -172,11 +204,11 @@ export class SessionProjectionCache extends Service {
       // 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)
+      const probe = await persistence.readFrom(meta.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)
+    const tail = await persistence.readFrom(meta.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.
@@ -188,10 +220,13 @@ export class SessionProjectionCache extends Service {
       // 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)
+      const whole = await persistence.readFrom(meta.id, 0, signal)
       restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
     }
-    await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
+    const path = this.cachePathFor(meta)
+    if (path !== undefined) {
+      await this.putSoft(path, meta.id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
+    }
     return restored.snapshot
   }
 
@@ -261,29 +296,31 @@ export class SessionProjectionCache extends Service {
     }
   }
 
-  /** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
-  private async put(id: SessionId, identity: CheckpointIdentity, rows: ProjectionCheckpoint): Promise<void> {
+  /** Atomically replace one session's cache file with its log identity and a detached snapshot of `rows`. */
+  private async put(path: string, identity: CheckpointIdentity, rows: ProjectionCheckpoint): Promise<void> {
     const detached = snapshotJsonValue(rows)
     if (detached === undefined) {
       throw new TypeError('projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)')
     }
-    await this.requireTable().put(id, { identity, rows: detached as CheckpointRecord['rows'] })
+    const record: CheckpointRecord = { identity, rows: detached as CheckpointRecord['rows'] }
+    await mkdir(dirname(path), { recursive: true })
+    await writeAtomic(path, JSON.stringify(record, null, 2))
   }
 
   /** 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> {
+  private async putSoft(
+    path: string,
+    id: SessionId,
+    identity: CheckpointIdentity,
+    rows: ProjectionCheckpoint,
+    what: string,
+  ): Promise<void> {
     try {
-      await this.put(id, identity, rows)
+      await this.put(path, 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')
-    return this.table
-  }
 }
 
 /** Project a header onto the identity fields a record is bound to. */

+ 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 = () => {}

+ 8 - 23
packages/session/session-projection-cache/src/spec.ts

@@ -1,17 +1,13 @@
 /**
- * The session-projcache 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`).
+ * The projection-cache record schema: one `projection_cache.json` per
+ * session, stored inside the session's own persistence directory (resolved
+ * through `sessionPersistence.locate(meta)`). The file holds the session's
+ * full projection checkpoint (`key → {ver, seq, val}` rows) plus the log
+ * identity it was folded from.
  * @module @deepseek-ai/dsh-session-projection-cache/src/spec
  */
 
 import { z } from 'zod'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
 
 /**
  * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)`
@@ -32,9 +28,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(),
@@ -57,14 +53,3 @@ export const checkpointRecord = z.object({
 
 /** One stored per-session checkpoint record, inferred from {@link checkpointRecord}. */
 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).
- */
-export const projectionCacheDomainSpec = defineDomain({
-  name: 'session_projcache',
-  version: 3,
-  tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
-})

+ 158 - 126
packages/session/session-projection-cache/tests/cache.spec.ts

@@ -2,21 +2,25 @@
  * 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 +
+ * cold-read ladder (cached file + readFrom tail + registry restore +
  * write-back; version bump and shrunk-log rows degrade to a full re-read).
+ * The durable medium is one `projection_cache.json` per session inside the
+ * session's persistence directory (resolved via `sessionPersistence.locate`).
  */
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mkdtemp, mkdir, 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, { SessionId } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Session, SessionEvent, SessionHeader } 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 SessionProjectionCache from '../src/index.ts'
+import { checkpointRecord } from '../src/spec.ts'
+import type { CheckpointRecord } from '../src/spec.ts'
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
@@ -51,8 +55,12 @@ 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[]>) {
+/** One session's cache file inside its persistence directory. */
+const cachePath = (root: string, id: Session['id']): string =>
+  join(root, String(id), 'projection_cache.json')
+
+/** A persistence double serving locate + readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
+function fakePersistence(root: string, 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`)
@@ -61,7 +69,10 @@ function fakePersistence(logs: Map<string, SessionEvent[]>) {
       events: events.filter(event => event.seq >= fromSeq),
     }
   })
-  return { readFrom }
+  return {
+    readFrom,
+    locate: (meta: SessionHeader) => ({ kind: 'jsonl', path: join(root, String(meta.id), 'session.jsonl') }),
+  }
 }
 
 /** Header shape for cachedSnapshot calls (fake logs stamp createdAt 0, no cwd). */
@@ -69,31 +80,28 @@ 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 root = options.root ?? await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+  roots.push(root)
   const logs = options.logs ?? new Map<string, 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.sessionProjections.register(marksUnit(options.stateVersion))
-  const persistence = fakePersistence(logs)
+  const persistence = fakePersistence(root, 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, logs, fiber, persistence, cache: ctx.sessionProjectionCache }
 }
 
 const mark = (session: Session, marks: string[]): SessionEvent =>
@@ -102,42 +110,54 @@ 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 {
+    return checkpointRecord.parse(JSON.parse(await readFile(cachePath(root, id), 'utf8')))
+  } catch {
+    return undefined
+  }
+}
+
+/** 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
 }
 
-/** The stored medium rows for one session id (undefined = never written). */
-function storedRows(pool: MemoryMediaPool, id: Session['id']) {
-  return storedRecord(pool, id)?.rows
+/** Pre-seed one session's cache file with a stored checkpoint record. */
+async function seedRecord(
+  root: string,
+  id: string,
+  rows: CheckpointRecord['rows'],
+  identity: CheckpointRecord['identity'] = { createdAt: 0 },
+): Promise<void> {
+  await mkdir(dirname(cachePath(root, SessionId(id))), { recursive: true })
+  await writeFile(cachePath(root, SessionId(id)), JSON.stringify({ identity, rows }))
 }
 
-/** Wait until queued fail-soft writes (event-listener fire-and-forget) drain. */
-const settle = () => new Promise(resolve => setTimeout(resolve, 0))
+/** 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 })))
 })
 
 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
+    expect(await storedRows(root, session.id)).toBeUndefined() // throttled: no write yet
     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 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 +167,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)).toBeUndefined()
     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)).toBeUndefined()
+    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 +211,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,24 +221,46 @@ 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()
+    expect(await storedRows(root, armed.id)).toBeUndefined()
   })
 
   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)
+    // A file where a directory is needed makes the first write fail...
+    await writeFile(join(root, 'blocked'), '')
+    const logs = new Map<string, SessionEvent[]>()
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
+    ctx.sessionProjections.register(marksUnit())
+    let block = true
+    ctx.provide('sessionPersistence', {
+      readFrom: 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) }
+      },
+      // ...and the locate seam can be un-blocked to let the next write succeed.
+      locate: (meta: SessionHeader) => block
+        ? { kind: 'jsonl', path: join(root, 'blocked', String(meta.id), 'session.jsonl') }
+        : { kind: 'jsonl', path: join(root, String(meta.id), 'session.jsonl') },
+    } as never)
+    await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
     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.
+    block = false
     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'] })
   })
 })
 
@@ -236,43 +276,31 @@ 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('serves a cold session from the cache row plus a bounded tail read, and writes the refresh back', async () => {
-    const pool = new MemoryMediaPool()
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     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 })
+    await seedRecord(root, 'cold', { 'cache-test/marks': { ver: 1, seq: 1, val: { marks: ['a'] } } })
+    const { cache, persistence, root: sameRoot } = await harness({ root, logs })
     const id = SessionId('cold')
-    const snapshot = await cache.coldSnapshot(id)
+    const snapshot = await cache.coldSnapshot(headerOf(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'])
+    expect((await storedRows(sameRoot, 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 root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     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'))
+    await seedRecord(root, 'bumped', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['stale'] } } })
+    const { cache, persistence } = await harness({ root, logs, stateVersion: 2 })
+    const snapshot = await cache.coldSnapshot(headerOf(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)
@@ -280,11 +308,12 @@ describe('SessionProjectionCache cold read', () => {
   })
 
   it('detects a log shrunk below the row watermark and degrades to one full re-read', async () => {
-    const pool = new MemoryMediaPool()
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     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'))
+    await seedRecord(root, 'shrunk', { 'cache-test/marks': { ver: 1, seq: 9, val: { marks: ['ghost'] } } })
+    const { cache, persistence } = await harness({ root, logs })
+    const snapshot = await cache.coldSnapshot(headerOf(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.
@@ -293,12 +322,13 @@ describe('SessionProjectionCache cold read', () => {
   })
 
   it('discards malformed persisted state and degrades to one full re-read', async () => {
-    const pool = new MemoryMediaPool()
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     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 })
+    await seedRecord(root, 'malformed', { 'cache-test/marks': { ver: 1, seq: 1, val: { marks: 'not-an-array' } } })
+    const { cache, persistence } = await harness({ root, logs })
 
-    const snapshot = await cache.coldSnapshot(SessionId('malformed'))
+    const snapshot = await cache.coldSnapshot(headerOf(SessionId('malformed')))
 
     expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
     expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('malformed'), 1, undefined)
@@ -306,101 +336,103 @@ describe('SessionProjectionCache cold read', () => {
   })
 
   it('write-back failure is contained: the snapshot is still served', async () => {
-    const pool = new MemoryMediaPool()
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     const logs = new Map([['soft', storedLog([['a']])]])
-    const { ctx, cache } = await harness({ pool, logs })
+    const cacheFile = cachePath(root, SessionId('soft'))
+    await seedRecord(root, 'soft', { 'cache-test/marks': { ver: 1, seq: 0, val: { marks: [] } } })
+    const { ctx, cache } = await harness({ root, logs })
     const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
-    pool.failNextWrites = 1
-    const snapshot = await cache.coldSnapshot(SessionId('soft'))
+    // A directory where the cache file must land makes the atomic rename fail;
+    // the served snapshot is unaffected.
+    await rm(cacheFile)
+    await mkdir(cacheFile)
+    const snapshot = await cache.coldSnapshot(headerOf(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')
+    await expect(cache.coldSnapshot(headerOf(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 root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     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'))
+    await seedRecord(root, 'reborn', { 'cache-test/marks': { ver: 1, seq: 2, val: { marks: ['phantom'] } } }, { createdAt: 999 })
+    const { cache, root: sameRoot } = await harness({ root, logs })
+    const snapshot = await cache.coldSnapshot(headerOf(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 })
+    expect((await storedRecord(sameRoot, 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()
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    await seedRecord(root, 'all-stale', { 'cache-test/marks': { ver: 99, seq: 4, val: { marks: ['old'] } } })
+    const { cache } = await harness({ root })
+    expect(await 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 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()
+    expect((await cache.cachedSnapshot(headerOf(id, 0, '/work')))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
+    expect(await cache.cachedSnapshot(headerOf(id, 0, '/elsewhere'))).toBeUndefined()
+    expect(await cache.cachedSnapshot(headerOf(id, 0))).toBeUndefined()
   })
 
   it('dates an empty stored log at -1 in the zero-units topology', async () => {
-    const pool = new MemoryMediaPool()
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     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)
+    ctx.provide('sessionPersistence', fakePersistence(root, logs) as never)
     await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
-    await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('empty')))
+    await expect(ctx.sessionProjectionCache.coldSnapshot(headerOf(SessionId('empty'))))
       .resolves.toEqual({ asOfSeq: -1, values: {} })
   })
 
   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 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'] } } })
+    expect(await 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()
+    expect(await cache.cachedSnapshot(headerOf(id, 777))).toBeUndefined()
     // Unknown id: no block.
-    expect(cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
+    expect(await cache.cachedSnapshot(headerOf(SessionId('never-cached')))).toBeUndefined()
   })
 
   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 root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
     const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
     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)
+    ctx.provide('sessionPersistence', fakePersistence(root, logs) as never)
     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')))
+    await expect(ctx.sessionProjectionCache.coldSnapshot(headerOf(SessionId('absent')))).rejects.toThrow('not found')
+    await expect(ctx.sessionProjectionCache.coldSnapshot(headerOf(SessionId('bare'))))
       .resolves.toEqual({ asOfSeq: 2, values: {} })
   })
 })

+ 1 - 4
packages/session/session-projection-cache/tsconfig.json

@@ -27,10 +27,7 @@
       "path": "../session-projection"
     },
     {
-      "path": "../../storage/storage"
-    },
-    {
-      "path": "../../storage/storage-domain"
+      "path": "../../storage/storage-json"
     },
     {
       "path": "../../runtime-diagnostics/invariants"

+ 1 - 0
packages/storage/storage-json/src/index.ts

@@ -12,6 +12,7 @@ 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'
+export { writeAtomic } from './atomic.ts'
 
 /** Cordis plugin name. */
 export const name = 'storage-json'

+ 1 - 1
packages/subagent/subagent/src/list-children.ts

@@ -357,7 +357,7 @@ async function resolveColdIdentity(
   if (cache !== undefined) {
     let cached: SubagentIdentityProjection | null | undefined
     try {
-      cached = cache.cachedSnapshot(header)?.values.subagent
+      cached = (await cache.cachedSnapshot(header))?.values.subagent
     } catch {
       // Unlike the preparation fold below, a throwing cache read renders no
       // verdict: the cache is derived data, so its damage (a poisoned stored

+ 7 - 7
packages/subagent/subagent/tests/list-children.spec.ts

@@ -424,7 +424,7 @@ describe('SubagentRuntime.listChildren', () => {
     // seq 2 >= seedLength 0: the cached identity provably comes from the
     // child's own suffix, so it is final and the log is never re-read — the
     // divergent label proves the row, not the log, produced the entry.
-    ctx.sessionProjectionCache.cachedSnapshot = () => ({
+    ctx.sessionProjectionCache.cachedSnapshot = async () => ({
       asOfSeq: 2,
       values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } },
     })
@@ -454,7 +454,7 @@ describe('SubagentRuntime.listChildren', () => {
     }, events)
     // A creation-window checkpoint carried the ANCESTOR identity: its seq 2
     // fails the own-suffix gate (< seedLength 4), so preparation rules.
-    ctx.sessionProjectionCache.cachedSnapshot = () => ({
+    ctx.sessionProjectionCache.cachedSnapshot = async () => ({
       asOfSeq: 2,
       values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } },
     })
@@ -503,7 +503,7 @@ describe('SubagentRuntime.listChildren', () => {
       origin: 'subagent',
     }, childEvents(descriptorPayload('actually valid')))
     // A stale cached sentinel must not out-rank the authoritative re-fold.
-    ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } })
+    ctx.sessionProjectionCache.cachedSnapshot = async () => ({ asOfSeq: 0, values: { subagent: null } })
     const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
       kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable',
@@ -775,8 +775,8 @@ describe('SubagentRuntime.listChildren', () => {
     // The child's turn/end and disposal are the cache's mandatory checkpoint
     // points; both writes are fail-soft asynchronous, so wait for the row.
     const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId)
-    await vi.waitFor(() => {
-      expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined()
+    await vi.waitFor(async () => {
+      expect((await ctx.sessionProjectionCache.cachedSnapshot(header!))?.values.subagent).toBeDefined()
     }, { timeout: 5_000 })
     const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
@@ -802,7 +802,7 @@ describe('SubagentRuntime.listChildren', () => {
     expect(inspect).toHaveBeenCalledTimes(1)
     // A stored row whose cut predates the descriptor: the subagent key is
     // absent from the served values, and preparation still rules.
-    ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: {} })
+    ctx.sessionProjectionCache.cachedSnapshot = async () => ({ asOfSeq: 0, values: {} })
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
     expect(inspect).toHaveBeenCalledTimes(2)
   })
@@ -828,7 +828,7 @@ describe('SubagentRuntime.listChildren', () => {
       parentSession: parent.id,
       origin: 'subagent',
     }, childEvents(descriptorPayload('recovered child')))
-    ctx.sessionProjectionCache.cachedSnapshot = () => {
+    ctx.sessionProjectionCache.cachedSnapshot = async () => {
       // A poisoned stored row (any unit's) detonates at view time; the cache
       // is derived data, so its failure must not become a verdict.
       throw new Error('poisoned cache row')

+ 3 - 3
pnpm-lock.yaml

@@ -6217,6 +6217,9 @@ importers:
 
   packages/session/session-projection-cache:
     dependencies:
+      '@deepseek-ai/dsh-storage-json':
+        specifier: workspace:^
+        version: link:../../storage/storage-json
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
@@ -6242,9 +6245,6 @@ importers:
       '@deepseek-ai/dsh-storage':
         specifier: workspace:^
         version: link:../../storage/storage
-      '@deepseek-ai/dsh-storage-domain':
-        specifier: workspace:^
-        version: link:../../storage/storage-domain
 
   packages/session/session-stats:
     dependencies: