Browse Source

docs(session-projection-cache): sync the per-record domain medium across docs

Cache README (EN/ZH): the medium is the session_projcache domain in
per-record layout (one version-stamped document per session under the
json backend root), reads are synchronous from the domain's in-memory
tables, and the storage stack rides in base. storage-json README
documents both layouts and their contracts; web-app README notes that
storage and the projection cache live in the shared base. Regenerated:
session-projection subsystem catalog (sync cachedSnapshot, domain
medium), config-catalog (Config.root gone), module-graph (cache now
depends on storage-domain, not session-persistence), cli composition
(storage rows in base), and the projection-cache Agent Note — which now
records the file-root revision and its revert as rejected alternatives.
_Kerman 2 months ago
parent
commit
07cf16d57c

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
-2026-08-19-projection-cache-per-session-files.md: 62ba71bf1f33461ab0e43fa87d3177bd6649ef28
-2026-08-19-projection-cache-per-session-files.zh.md: e3af483711953fd5c1c471c105725eca8e0c300c
+2026-08-19-projection-cache-per-session-files.md: 0a44ca96384bc4935a95a868da941f43fee70c46
+2026-08-19-projection-cache-per-session-files.zh.md: fa30c03e88015b78996e7cae5224d2260670542b

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

@@ -10,20 +10,21 @@ The persisted projection cache was one global `session_projcache.json` — a `se
 
 ## Decision
 
-The cache becomes one `projection_cache.json` per session, stored under the cache's own storage root — `<root>/<session-id>/projection_cache.json` (the session id is a code-generated string, used directly as the directory name). The cache owns its directory tree and never consults the persistence layer: no `locate`, no dependency on which backend is mounted. The cache service keeps every other responsibility: checkpoint fold, write policy (turn/end + disposal mandatory, count/interval throttle), fail-soft durability, and the listing read.
+The cache opens the `session_projcache` storage domain in the new `per-record` layout, added to the json backend: one version-stamped document per session at `<root>/session_projcache/sessions/<id>.json`, owned by the storage stack — `storage` / `storage-json` / `storage-domain` live in the shared base bundle alongside the cache, and the cache itself is a plain domain consumer again. The cache never consults the persistence layer: no `locate`, no dependency on which backend is mounted.
 
-Reading a cache row is one file read, so `cachedSnapshot(meta)` is async. The cache no longer runs a cold-refold ladder (that would require reading the session log, which belongs to the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself. Session directories and cache files are created owner-only (`0o700`/`0o600`) via `@deepseek-ai/dsh-atomic-write`.
+Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a synchronous lookup in the domain's in-memory tables (zero I/O), and every write queues on the domain's per-unit write chain, mutating memory only after durability — no direct disk reads that could lag the throttled writes. The cache keeps every other responsibility: checkpoint fold, write policy (turn/end + disposal mandatory, count/interval throttle), fail-soft durability, and the listing read. `cachedSnapshot(meta)` is synchronous. The cache runs no cold-refold ladder (that would require reading the session log, which belongs to the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself. The json backend creates its tree owner-only (`0o700`).
 
 ## Consequences
 
-- Per-session write isolation: each throttled write replaces only that session's small file, removing the global write amplification. Writes to one cache file serialize, so a newer cut never lands before an older one; plugin disposal drains in-flight writes.
-- Listing pays N small file reads instead of one big load; a session without a cache file simply lacks the projection column.
+- Per-session write isolation: each throttled write replaces only that session's small document, removing the global write amplification. The domain write chain serializes writes, so a newer cut never lands before an older one; domain close drains in-flight writes.
+- Listing is a synchronous in-memory read; a session without a record document simply lacks the projection column.
+- The per-record contract scopes failure: a malformed or stale-version document reads as an absent record at open, so one bad file never bricks the cache, and a checkpoint schema bump discards stale sessions per record instead of rejecting the whole domain.
 - No migration: the cache is derived data, never an authority. An obsolete cache (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.
+- The cache record is bound to the same log lifecycle as before: the stored `{createdAt, cwd}` identity guards against a recreated id.
 
 ## Alternatives considered
 
 - **Keep the global sessions table.** Preserves one-load listing, but keeps the global write amplification and single-file blast radius that motivated the change.
-- **Resolve the path through `sessionPersistence.locate(meta)`** (the file beside the session log). Rejected: the cache would have to guess "beside the log" from a log artifact path (`dirname` + fixed filename), coupling the cache to the persistence service and to a backend's layout. The cache keeps its own tree instead.
-- **Replicate the session-directory layout inside the cache.** Rejected: the persistence backend already owns that layout; the cache does not need the log's project/session directory structure for its own derived files.
-- **Use the existing `@deepseek-ai/dsh-atomic-write`.** Adopted: it is the repo's single atomic whole-file write primitive (owner-only modes included); exporting a second one from the storage-json backend was rejected.
+- **Cache-owned per-session files** (`<root>/<session-id>/projection_cache.json`, the first revision of this change). Tried and reverted in review: the cache hand-rolled the medium — paths, per-path write chains, in-flight tracking, owner-only file modes, and a sqlite no-path special case — and its listing read hit the disk directly on every call while writes were throttled, so reads and writes were never consistent.
+- **Resolve the path through `sessionPersistence.locate(meta)`** (the file beside the session log). Rejected: the cache would have to guess "beside the log" from a log artifact path (`dirname` + fixed filename), coupling the cache to the persistence service and to a backend's layout.
+- **Make `per-record` a mode of the existing unit instead of a separate unit class.** Rejected: the two layouts have genuinely different state models — `single` is memory-authoritative with whole-file publish, `per-record` is stateless (the directory is the state; `loadAll` re-reads the tree) — so they are separate small classes behind one backend, with record keys validated path-safe instead of encoded.

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

@@ -10,20 +10,21 @@ Status: implemented
 
 ## Decision
 
-缓存改为每会话一个 `projection_cache.json`,存放在缓存自己的存储根下——`<root>/<session-id>/projection_cache.json`(session id 是代码生成的字符串,直接用作目录名)。缓存拥有自己的目录树,绝不咨询持久化层:没有 `locate`、不依赖挂载的是哪个后端。缓存服务保留其余全部职责:检查点折叠、写策略(turn/end + dispose 强制点、count/interval 节流)、fail-soft 持久化与列表读。
+缓存打开采用新增 `per-record` 布局的 `session_projcache` 存储域:每个会话一个带版本戳的文档,位于 `<root>/session_projcache/sessions/<id>.json`,介质归存储栈所有——`storage` / `storage-json` / `storage-domain` 与缓存一起落在共享 base 装配里,缓存重新变回纯粹的域消费方。缓存绝不咨询持久化层:没有 `locate`、不依赖挂载的是哪个后端。
 
-读取缓存行现在是一次文件读取,因此 `cachedSnapshot(meta)` 变为异步。缓存不再运行冷重折叠阶梯(那需要读取会话日志,属于持久化层的职责);需要保证冷快照的消费方自行从日志重折叠。会话目录与缓存文件经 `@deepseek-ai/dsh-atomic-write` 以仅属主权限(`0o700`/`0o600`)创建。
+读写共享同一份一致状态:每次读取(`cachedSnapshot`)都是对域内存表的同步查找(零 I/O);每次写入排进该域的单条写链,先落盘成功才改内存——不再有落后于节流写入的直读磁盘。缓存保留其余全部职责:检查点折叠、写策略(turn/end + dispose 强制点、count/interval 节流)、fail-soft 持久化与列表读。`cachedSnapshot(meta)` 是同步的。缓存不运行冷重折叠阶梯(那需要读取会话日志,属于持久化层的职责);需要保证冷快照的消费方自行从日志重折叠。json 后端以仅属主权限(`0o700`)创建自己的目录树。
 
 ## Consequences
 
-- 每会话写入隔离:每次节流写入只替换该会话的小文件,消除全局写放大。同一缓存文件的写入被串行化,新切面绝不会先于旧切面落盘;插件释放时会排空在途写入。
-- 列表读取从一次大加载变为 N 次小文件读取;没有缓存文件的会话只是缺少投影列。
+- 每会话写入隔离:每次节流写入只替换该会话的小文档,消除全局写放大。域写链将写入串行化,新切面绝不会先于旧切面落盘;域关闭时会排空在途写入。
+- 列表读取是同步内存读;没有记录文档的会话只是缺少投影列。
+- per-record 契约把故障范围缩小到单记录:畸形或过期版本的文档在打开时读作"无此记录",单个坏文件不会拖垮整个缓存;检查点 schema 升级按会话丢弃过期行,而不是拒绝整个域。
 - 无需迁移:缓存是派生数据,绝非权威。过时的缓存(任何更早格式)从不被读取——首次冷读从日志重折叠并写出当前格式。
-- 缓存文件仍绑定同一日志生命周期:存储的 `{createdAt, cwd}` 身份防止被重建的 id 误导。
+- 缓存记录仍绑定同一日志生命周期:存储的 `{createdAt, cwd}` 身份防止被重建的 id 误导。
 
 ## Alternatives considered
 
 - **保留全局 sessions 表。** 保留一次加载式列表,但保留了促成此改动的全局写放大与单文件爆炸半径。
-- **经 `sessionPersistence.locate(meta)` 解析路径**(文件放在会话日志旁)。未采用:缓存得从日志 artifact 路径"猜"日志旁边(`dirname` + 固定文件名),把缓存耦合到持久化服务与后端的布局。缓存改用自有目录树。
-- **在缓存内复刻会话目录布局。** 未采用:持久化后端已拥有该布局;缓存为自己派生的文件不需要日志的 project/session 目录结构。
-- **使用现有 `@deepseek-ai/dsh-atomic-write`。** 采纳:它是仓库唯一的原子整文件写入原语(含仅属主权限);从 storage-json 后端再导出一套并行实现被否决。
+- **缓存自持的每会话文件**(`<root>/<session-id>/projection_cache.json`,本改动的第一版)。试过并在评审中回退:缓存手搓了介质——路径、按路径的写链、在途跟踪、仅属主文件权限,以及 sqlite 无路径特判——而且它的列表读每次调用都直读磁盘、写却在节流,读写永不一致。
+- **经 `sessionPersistence.locate(meta)` 解析路径**(文件放在会话日志旁)。未采用:缓存得从日志 artifact 路径"猜"日志旁边(`dirname` + 固定文件名),把缓存耦合到持久化服务与后端的布局。
+- **把 `per-record` 做成既有单元的一种模式而非独立单元类。** 未采用:两种布局的状态模型本质不同——`single` 内存权威、整文件发布;`per-record` 无状态(目录即状态,`loadAll` 重扫目录树)——所以它们是同一后端下的两个小型独立类,记录键做路径安全校验而非编码。

+ 12 - 3
apps/cli/composition.md

@@ -44,14 +44,20 @@ flowchart LR
   cfg --> plugin_dsh_base_llm_pi_ai
   plugin_dsh_base_session_persistence_jsonl["session-persistence-jsonl<br/>@deepseek-ai/dsh-session-persistence-jsonl"]
   cfg --> plugin_dsh_base_session_persistence_jsonl
-  plugin_dsh_base_session_projection_cache["session-projection-cache<br/>@deepseek-ai/dsh-session-projection-cache"]
-  cfg --> plugin_dsh_base_session_projection_cache
   plugin_dsh_base_attachment_local["attachment-local<br/>@deepseek-ai/dsh-attachment-local"]
   cfg --> plugin_dsh_base_attachment_local
   plugin_dsh_base_session_query_sqlite["session-query-sqlite<br/>@deepseek-ai/dsh-session-query-sqlite"]
   cfg --> plugin_dsh_base_session_query_sqlite
   plugin_dsh_base_session_projection["session-projection<br/>@deepseek-ai/dsh-session-projection"]
   cfg --> plugin_dsh_base_session_projection
+  plugin_dsh_base_storage["storage<br/>@deepseek-ai/dsh-storage"]
+  cfg --> plugin_dsh_base_storage
+  plugin_dsh_base_storage_json["storage-json<br/>@deepseek-ai/dsh-storage-json"]
+  cfg --> plugin_dsh_base_storage_json
+  plugin_dsh_base_storage_domain["storage-domain<br/>@deepseek-ai/dsh-storage-domain"]
+  cfg --> plugin_dsh_base_storage_domain
+  plugin_dsh_base_session_projection_cache["session-projection-cache<br/>@deepseek-ai/dsh-session-projection-cache"]
+  cfg --> plugin_dsh_base_session_projection_cache
   plugin_dsh_base_session_telemetry_otel["session-telemetry-otel<br/>@deepseek-ai/dsh-session-telemetry-otel"]
   cfg --> plugin_dsh_base_session_telemetry_otel
   plugin_dsh_base_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
@@ -188,10 +194,13 @@ flowchart LR
 | `credentials` | `@deepseek-ai/dsh-credentials-local` |
 | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
 | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
-| `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
 | `attachment-local` | `@deepseek-ai/dsh-attachment-local` |
 | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
 | `session-projection` | `@deepseek-ai/dsh-session-projection` |
+| `storage` | `@deepseek-ai/dsh-storage` |
+| `storage-json` | `@deepseek-ai/dsh-storage-json` |
+| `storage-domain` | `@deepseek-ai/dsh-storage-domain` |
+| `session-projection-cache` | `@deepseek-ai/dsh-session-projection-cache` |
 | `session-telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
 | `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
 | `sandbox` | `@deepseek-ai/dsh-sandbox-local` |

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: b5c487120f1272ddb1da29d8ed1230a5c1f3d0a9
-config-catalog.zh.md: 435ba990e69f2efc157edc793568cd889e639d0a
+config-catalog.md: 36ae870aeb7f68bf2de1ea926101aabb7d3606c2
+config-catalog.zh.md: 210c6fb71ae9837737c67958bd57af9be48b2a3e

+ 4 - 6
docs/config-catalog.md

@@ -1698,7 +1698,7 @@ Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packa
 
 ## `@deepseek-ai/dsh-session-projection-cache`
 
-Requires: `sessionProjections` · `sessions`
+Requires: `storageDomain` · `sessionProjections` · `sessions`
 
 ```ts config-catalog
 /**
@@ -1708,8 +1708,6 @@ Requires: `sessionProjections` · `sessions`
  * disposal) are policy, not tunables, and always fire.
  */
 export interface Config {
-  /** Directory holding one `<session-id>/projection_cache.json` per session. */
-  root: string
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
   writeEveryEvents: number
   /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
@@ -1717,7 +1715,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:43`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 
@@ -2042,12 +2040,12 @@ Requires: `storage`
  * location explicitly.
  */
 export interface Config {
-  /** Directory holding one `<unit>.json` file per unit. */
+  /** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
   root: string
 }
 ```
 
-Source: [`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts)
+Source: [`packages/storage/storage-json/src/index.ts:28`](../packages/storage/storage-json/src/index.ts)
 
 <a id="deepseek-aidsh-storage-sqlite"></a>
 

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

@@ -1700,7 +1700,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
 
 ## `@deepseek-ai/dsh-session-projection-cache`
 
-需要:`sessionProjections` · `sessions`
+需要:`storageDomain` · `sessionProjections` · `sessions`
 
 ```ts config-catalog
 /**
@@ -1710,8 +1710,6 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
  * disposal) are policy, not tunables, and always fire.
  */
 export interface Config {
-  /** Directory holding one `<session-id>/projection_cache.json` per session. */
-  root: string
   /** Committed events per session that force a durable checkpoint write between mandatory points. */
   writeEveryEvents: number
   /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
@@ -1719,7 +1717,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/session/session-projection-cache/src/index.ts:48`](../packages/session/session-projection-cache/src/index.ts)
+来源:[`packages/session/session-projection-cache/src/index.ts:43`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 
@@ -2044,12 +2042,12 @@ export interface Config {
  * location explicitly.
  */
 export interface Config {
-  /** Directory holding one `<unit>.json` file per unit. */
+  /** Directory holding one `<unit>.json` file (or `<unit>/` tree) per unit. */
   root: string
 }
 ```
 
-来源:[`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts)
+来源:[`packages/storage/storage-json/src/index.ts:28`](../packages/storage/storage-json/src/index.ts)
 
 <a id="deepseek-aidsh-storage-sqlite"></a>
 

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 38d3acc73175d504dadf22e4a5317e5f84ff5be8
-module-graph.zh.md: 76880c34aef07f877147a90f62dacd2b6877de41
+module-graph.md: a478877ffd9c72b67f7ca3b6d520b350f5fd0544
+module-graph.zh.md: 85979bc19246192cd8595da87abc7ae7f1e158d0

+ 1 - 2
docs/module-graph.md

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

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

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

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: f17e70d3dd4a4d89381bbe2be1428647a612ff9a
-session-projection.zh.md: cc6a4edf1ba394cf71abfa64ccff6667566f5d9d
+session-projection.md: 13c44fe9d52becaa18ab0e164e3dc7e607565bef
+session-projection.zh.md: f727b554c0779296b139b5981c344373a59c16a6

+ 13 - 11
docs/subsystems/session-projection.md

@@ -112,27 +112,29 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.sessionProjectionCache` — `SessionProjectionCache`
 
-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 cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write. The cache owns its directory tree and never consults the persistence layer.
+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 cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
 
 ```ts cordis-catalog
 /**
- * The 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).
+ * The zero-I/O listing read: whole values viewed straight from the stored
+ * rows (version-matching keys only), each cut carried with its watermark so
+ * a client value store can seed under its higher-seq-wins rule — as stale
+ * as the last durable checkpoint but never wrong, and never from an
+ * unrelated log (the caller's header is the identity witness). Fresher
+ * paths (the history tail baseline) supersede these values whenever a
+ * session is actually opened.
  * @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.
  */
-async cachedSnapshot(meta: SessionHeader): Promise<ProjectionSnapshot | undefined>
+cachedSnapshot(meta: SessionHeader): 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 session's cache
- * file is replaced. NOT fail-soft — callers on the fail-soft paths contain
- * it.
+ * this boundary (states are live references), then the session's record is
+ * replaced on the domain's write chain. NOT fail-soft — callers on the
+ * fail-soft paths contain it.
  * @param session - the live session to checkpoint.
  * @returns resolution after durability and event emission.
  */
@@ -141,7 +143,7 @@ async write(session: Session): Promise<void>
 
 Types: [Session](session.md) · [SessionHeader](persistence.md)
 
-Source: [`packages/session/session-projection-cache/src/index.ts:80`](../../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../packages/session/session-projection-cache/src/index.ts)
 
 <a id="ctxsessionprojections--sessionprojectionregistry"></a>
 

+ 13 - 11
docs/subsystems/session-projection.zh.md

@@ -112,27 +112,29 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
 
 ### `ctx.sessionProjectionCache` — `SessionProjectionCache`
 
-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 cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write. The cache owns its directory tree and never consults the persistence layer.
+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 cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.
 
 ```ts cordis-catalog
 /**
- * The 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).
+ * The zero-I/O listing read: whole values viewed straight from the stored
+ * rows (version-matching keys only), each cut carried with its watermark so
+ * a client value store can seed under its higher-seq-wins rule — as stale
+ * as the last durable checkpoint but never wrong, and never from an
+ * unrelated log (the caller's header is the identity witness). Fresher
+ * paths (the history tail baseline) supersede these values whenever a
+ * session is actually opened.
  * @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.
  */
-async cachedSnapshot(meta: SessionHeader): Promise<ProjectionSnapshot | undefined>
+cachedSnapshot(meta: SessionHeader): 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 session's cache
- * file is replaced. NOT fail-soft — callers on the fail-soft paths contain
- * it.
+ * this boundary (states are live references), then the session's record is
+ * replaced on the domain's write chain. NOT fail-soft — callers on the
+ * fail-soft paths contain it.
  * @param session - the live session to checkpoint.
  * @returns resolution after durability and event emission.
  */
@@ -141,7 +143,7 @@ async write(session: Session): Promise<void>
 
 Types: [Session](session.md) · [SessionHeader](persistence.md)
 
-Source: [`packages/session/session-projection-cache/src/index.ts:80`](../../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../packages/session/session-projection-cache/src/index.ts)
 
 <a id="ctxsessionprojections--sessionprojectionregistry"></a>
 

+ 1 - 1
packages/bundle/web-app/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md
-README.md: 28fb5b3dcfc7fbb912493a6b97495e2ed5a3eece
+README.md: 2d9a9482dba01b8c748101becf405f59824a691d
 README.zh.md: 92157f05497e53c48506666a3a53d638d98a7c9e

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

@@ -2,7 +2,7 @@
 
 English | [中文](README.zh.md)
 
-The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
+The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). The storage stack and the projection cache ride in the shared base bundle; the web overlay's workspace and message-feedback rows consume the base `storageDomain` service. That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
 
 ## Model retry defaults
 

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

@@ -1085,17 +1085,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'sessionProjectionCache',
     summary: 'The persisted projection cache service.',
-    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 cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write. The cache owns its directory tree and never consults the persistence layer.',
+    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 cached rows for a session header. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write.',
     methods: [
       {
-        signature: '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).',
+        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) 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 session\'s cache file is replaced. NOT fail-soft — callers on the fail-soft paths contain it.',
+        description: 'Durably checkpoint one live session NOW (both mandatory points call this; tests and carriers may too). The registry cut is snapshotted at this boundary (states are live references), then the session\'s record is replaced on the domain\'s write chain. NOT fail-soft — callers on the fail-soft paths contain it.',
         parameters: [{ name: 'session', description: 'the live session to checkpoint.' }],
         returns: 'resolution after durability and event emission.',
       },
@@ -3083,7 +3083,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'DomainSpec',
-    declaration: 'export interface DomainSpec {\n    readonly name: string;\n    readonly version: number;\n    readonly global?: DomainGlobalSpec<unknown>;\n    readonly tables: Record<string, DomainTableSpec>;\n}',
+    declaration: 'export interface DomainSpec {\n    readonly name: string;\n    readonly version: number;\n    readonly layout?: \'single\' | \'per-record\';\n    readonly global?: DomainGlobalSpec<unknown>;\n    readonly tables: Record<string, DomainTableSpec>;\n}',
   },
   {
     name: 'DomainTableSpec',
@@ -3359,7 +3359,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'KvUnitDescriptor',
-    declaration: 'export interface KvUnitDescriptor {\n    readonly name: string;\n    readonly version: number;\n    readonly tables: readonly string[];\n    readonly hasGlobal: boolean;\n}',
+    declaration: 'export interface KvUnitDescriptor {\n    readonly name: string;\n    readonly version: number;\n    readonly tables: readonly string[];\n    readonly hasGlobal: boolean;\n    readonly layout?: \'single\' | \'per-record\';\n}',
   },
   {
     name: 'LlmAdapter',

+ 2 - 2
packages/session/session-projection-cache/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md
-README.md: 08fb59af8e2e07c3ee8864d4a06c6c2c760df104
-README.zh.md: fed390a2ecea19372cc40a2d72e7652f1c4a9ab1
+README.md: fd7533b23c842fe5da45e31476be0daa49b65edf
+README.zh.md: 13990cd5b65baac9ac3222222dc0ec3de1cb5b15

+ 12 - 10
packages/session/session-projection-cache/README.md

@@ -2,17 +2,18 @@
 
 English | [中文](README.zh.md)
 
-The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one `projection_cache.json` per session under the cache's own storage root (`<root>/<session-id>/projection_cache.json`). The cache owns its directory tree and never consults the persistence layer. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
+The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one version-stamped document per session on the `session_projcache` storage domain in `per-record` layout (the shipped json backend lands each session's record at `<root>/session_projcache/sessions/<id>.json`). The cache never consults the persistence layer. Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
 
 A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to:
 
+- **Reads and writes share one coherent state.** Every read is a synchronous lookup in the domain's in-memory tables; every write queues on the domain's per-unit write chain and mutates memory only after durability, so a read can never go around the write chain to the medium (no direct disk reads, no torn values).
 - **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write self-heals. A crash between writes costs a longer tail replay, never a wrong value.
 - **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
-- **A row must pass the live unit's `stateSchema`.** A malformed file reads as "no cache row", so the cold path refolds 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. Writes to one cache file serialize, so a newer cut never lands before an older one.
+- **A row must pass the live unit's `stateSchema`.** A malformed or stale record document reads as "no cache row" at open, so the cold path refolds from the log.
+- **Whole-record writes.** Each write atomically replaces the session's record document (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud. The domain write chain serializes writes, so a newer cut never lands before an older one.
 - **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live header is the witness) before accepting a record, so a deleted-then-recreated id cannot let an old record seed state folded from an unrelated log.
-- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache file lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
-- **The cache owns its tree, private by default.** Session directories and cache files are created owner-only (`0o700`/`0o600`). The cache does not depend on which persistence backend is mounted — no `locate`, no per-session-dir probing.
+- **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
+- **The medium is the domain's, private by default.** The json backend creates its tree owner-only (`0o700`); the cache does not depend on which persistence backend is mounted — no `locate`, no per-session-dir probing.
 
 ## Write policy
 
@@ -25,26 +26,27 @@ Two mandatory points, throttled in between:
 | `writeEveryEvents` committed events | Config throttle (count). |
 | `writeIntervalMs` since the first dirty event | Config throttle (interval). |
 
-`root` and both throttle triggers are required `Config` fields (no defaults): the cache root and flush cadence are deployment choices stated in cordis.yml.
+Both throttle triggers are required `Config` fields (no defaults): the flush cadence is a deployment choice stated in cordis.yml.
 
 ## Listing read (`cachedSnapshot(meta)`)
 
-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 or malformed file, or no usable rows); the api-proxy list carrier turns that into an absent column.
+A synchronous, zero-I/O read from the domain's in-memory tables: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, absent or foreign record document, or no usable rows); the api-proxy list carrier turns that into an absent column.
 
 `write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment).
 
 ## Composition
 
+The cache opens its domain through the storage stack, so base mounts `storage`, `storage-json` (root `dshHomePath('storages')`), and `storage-domain` (`backend: json`) before it:
+
 ```yaml
 - id: session-projection-cache
   name: '@deepseek-ai/dsh-session-projection-cache'
   config:
-    root: !!js dshHomePath('projections')
     writeEveryEvents: 200
     writeIntervalMs: 5000
 ```
 
-Injects `sessionProjections`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
+Injects `storageDomain`, `sessionProjections`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
 
 ## Model Experience
 
@@ -58,4 +60,4 @@ None; the cache never assembles or sends provider requests.
 
 - **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself.
 - **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window.
-- **No cache-side cold refold** — the cache serves and refreshes its files but never reads the session log (it does not depend on the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself.
+- **No cache-side cold refold** — the cache serves and refreshes its rows but never reads the session log (it does not depend on the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself.

+ 12 - 10
packages/session/session-projection-cache/README.zh.md

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

+ 2 - 2
packages/storage/storage-json/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/storage/storage-json/README.md
-README.md: c3417846a70f8a227a19a5abf834156d530e3597
-README.zh.md: e66c711dca838620eb9c90a3edacd2639970505e
+README.md: 7024e297de039526236ffce1f4c5251f56151c62
+README.zh.md: f14062ad4be69303ee41faac265fda64f1b030a4

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

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

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

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