Преглед на файлове

refactor(session-projection): compact checkpoint row fields to ver/seq/val

The persisted row (sessionId, key, stateVersion, observedSeq, state)
becomes (sessionId, key, ver, seq, val) — the cache medium repeats these
three names for every unit of every session, so the long forms dominated
the JSON payload. ProjectionCheckpointRow and the checkpointRow zod spec
rename together; the domain spec bumps to v3 (cache semantics: the old
medium is discarded, not migrated). The unit-facing declaration keeps
stateVersion — only the persisted/checkpoint row shape changes.
imccyu преди 2 месеца
родител
ревизия
b4bc4f382e

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
-2026-07-27-session-projection-and-command-log.md: 51cc60208ecafd55738c12f1887056c7b0427117
-2026-07-27-session-projection-and-command-log.zh.md: 71f6f6ea944c7c1bdd7e560ec8f0dc2528522fc1
+2026-07-27-session-projection-and-command-log.md: 6a073c956c27bbfc65cff2d4f44ca12023df0cd5
+2026-07-27-session-projection-and-command-log.zh.md: 500f07968db049e4a174ff3b7a075bfe095283db

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md

@@ -51,7 +51,7 @@ declare module 'cordis' {
 
 - Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
 - **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
-- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
+- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
 - A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
 - Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
 - The package owns `./invariant` (every served key has a live registration).
@@ -133,7 +133,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
 2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile).
 3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1.
 4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
-5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
+5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, ver, seq, val)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md

@@ -51,7 +51,7 @@ declare module 'cordis' {
 
 - 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通(host 侧单元、协议块、React 钩子)——没有第二张 DTO 表,也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
 - **host 是投影唯一的计算地点。** 框架正向驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
-- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
+- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
 - 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
 - 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
 - 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
@@ -133,7 +133,7 @@ host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `
 2. **客户端基座**:通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1(在此之前 fixture(测试前置数据)喂合成帧)。
 3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线、`{matched, commandId?}` 准入。与 1 并行。
 4. **领域重新对接**(在 1+2 之后):先 todo(单元进 `tool-todo`,删掉搭载字段),再 plan(双事件单元、RPC 下线、开关改发 `/plan`),最后 goal(`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject)。
-5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
+5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, ver, seq, val)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
 
 ## Alternatives considered
 

+ 9 - 9
docs/cordis-catalog/services.md

@@ -1191,9 +1191,9 @@ snapshot(session: Session): ProjectionSnapshot
  * State-level checkpoint of every registered unit for one session, read
  * from the watermark cache (missing cells fold lazily over the in-memory
  * log). This is the write side of the persisted projection cache: the
- * returned rows are the `(key → {stateVersion, observedSeq, state})` part
- * of the durable `(sessionId, key, stateVersion, observedSeq, state)`
- * rows. Every `state` is a DETACHED structured clone — never the live
+ * returned rows are the `(key → {ver, seq, val})` part of the durable
+ * `(sessionId, key, ver, seq, val)`
+ * rows. Every `val` is a DETACHED structured clone — never the live
  * cell reference: the watermark cache is this registry's authoritative
  * mutable state, and a caller reaching the live reference could corrupt
  * every subsequent snapshot and frame through it (plain JSON by the unit
@@ -1206,7 +1206,7 @@ checkpoint(session: Session): ProjectionCheckpoint
 /**
  * The stored seq a {@link restore} tail read over `checkpoint` must start
  * at: one event BELOW the lowest usable watermark (a row is usable when
- * its `stateVersion` matches the live unit; an absent or mismatched row
+ * its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
  * pulls the floor to `0` — that key must refold the full log). The
  * one-below anchor is load-bearing: the tail then proves how far the
  * stored log still extends, so {@link restore} can detect a log that
@@ -1223,7 +1223,7 @@ restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
 
 /**
  * View a checkpoint's rows without any log read: for every registered
- * unit whose row's `stateVersion` matches, serve the schema-validated
+ * unit whose row's `ver` matches, serve the schema-validated
  * `view` of the stored state; mismatched or absent rows leave their key
  * absent (a cold or listing consumer treats it as not-yet-available and a
  * fuller read path refolds it). The zero-I/O rung of the read ladder —
@@ -1241,9 +1241,9 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
  * `readFrom(id, restoreFloor(checkpoint))` and that same floor as
  * `baseSeq`; the floor's one-below anchor makes the supplied end honest,
  * so a shrunk log is detected here. A row is usable iff its
- * `stateVersion` matches the live unit, it does not predate `baseSeq`
- * (`observedSeq >= baseSeq - 1`), and it does not claim events past the
- * supplied end (`observedSeq <= endSeq`); an unusable row is discarded
+ * `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
+ * (`seq >= baseSeq - 1`), and it does not claim events past the
+ * supplied end (`seq <= endSeq`); an unusable row is discarded
  * and its key refolds from `init` — which is only sound over the full
  * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
  * from seq 0, e.g. after a crash-repair truncation shrank the log below
@@ -1260,7 +1260,7 @@ restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseS
 
 Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
 
-Source: [`packages/session-projection/session-projection/src/index.ts:157`](../../packages/session-projection/session-projection/src/index.ts)
+Source: [`packages/session-projection/session-projection/src/index.ts:156`](../../packages/session-projection/session-projection/src/index.ts)
 
 ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam)
 

+ 5 - 5
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -574,19 +574,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       {
         signature: 'checkpoint(session: Session): ProjectionCheckpoint',
-        jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {stateVersion, observedSeq, state})` part\n * of the durable `(sessionId, key, stateVersion, observedSeq, state)`\n * rows. Every `state` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */',
+        jsDoc: '/**\n * State-level checkpoint of every registered unit for one session, read\n * from the watermark cache (missing cells fold lazily over the in-memory\n * log). This is the write side of the persisted projection cache: the\n * returned rows are the `(key → {ver, seq, val})` part of the durable\n * `(sessionId, key, ver, seq, val)`\n * rows. Every `val` is a DETACHED structured clone — never the live\n * cell reference: the watermark cache is this registry\'s authoritative\n * mutable state, and a caller reaching the live reference could corrupt\n * every subsequent snapshot and frame through it (plain JSON by the unit\n * contract, so the clone is total).\n * @param session - the session whose unit states are checkpointed.\n * @returns one row per registered key; empty when no unit is registered.\n */',
       },
       {
         signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined',
-        jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `stateVersion` matches the live unit; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n *   when no unit is registered (no read needed — {@link restore} would\n *   serve empty values regardless).\n */',
+        jsDoc: '/**\n * The stored seq a {@link restore} tail read over `checkpoint` must start\n * at: one event BELOW the lowest usable watermark (a row is usable when\n * its `ver` matches the live unit\'s `stateVersion`; an absent or mismatched row\n * pulls the floor to `0` — that key must refold the full log). The\n * one-below anchor is load-bearing: the tail then proves how far the\n * stored log still extends, so {@link restore} can detect a log that\n * shrank below a row\'s watermark (crash-repair truncation) instead of\n * serving the stale row as current — an empty tail read from the anchor\n * yields an end below every watermark and the restore rejects for a full\n * re-read.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns the seq to hand the persistence `readFrom`, or `undefined`\n *   when no unit is registered (no read needed — {@link restore} would\n *   serve empty values regardless).\n */',
       },
       {
         signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>',
-        jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `stateVersion` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */',
+        jsDoc: '/**\n * View a checkpoint\'s rows without any log read: for every registered\n * unit whose row\'s `ver` matches, serve the schema-validated\n * `view` of the stored state; mismatched or absent rows leave their key\n * absent (a cold or listing consumer treats it as not-yet-available and a\n * fuller read path refolds it). The zero-I/O rung of the read ladder —\n * values are as stale as their rows, never wrong.\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @returns whole values per key with a usable row; empty when none.\n */',
       },
       {
         signature: 'restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
-        jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `stateVersion` matches the live unit, it does not predate `baseSeq`\n * (`observedSeq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`observedSeq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n *   supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n *   refreshed checkpoint rows at that cut, ready for a durable write-back.\n */',
+        jsDoc: '/**\n * Cold read: fold every registered unit over a stored log suffix, seeding\n * each from its checkpoint row when usable — the one read recipe (cached\n * state + forward tail replay + `view`) applied without a live `Session`.\n * Call with the events returned by a persistence\n * `readFrom(id, restoreFloor(checkpoint))` and that same floor as\n * `baseSeq`; the floor\'s one-below anchor makes the supplied end honest,\n * so a shrunk log is detected here. A row is usable iff its\n * `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq`\n * (`seq >= baseSeq - 1`), and it does not claim events past the\n * supplied end (`seq <= endSeq`); an unusable row is discarded\n * and its key refolds from `init` — which is only sound over the full\n * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads\n * from seq 0, e.g. after a crash-repair truncation shrank the log below\n * a row\'s watermark).\n * @param checkpoint - persisted rows for one session (possibly stale or empty).\n * @param events - the stored events with `seq >= baseSeq`, in seq order.\n * @param baseSeq - the seq `events` starts at (its first event\'s seq when non-empty).\n * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last\n *   supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the\n *   refreshed checkpoint rows at that cut, ready for a durable write-back.\n */',
       },
     ],
   },
@@ -1899,7 +1899,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ProjectionCheckpointRow',
-    declaration: 'export interface ProjectionCheckpointRow {\n    stateVersion: number;\n    observedSeq: number;\n    state: unknown;\n}',
+    declaration: 'export interface ProjectionCheckpointRow {\n    ver: number;\n    seq: number;\n    val: unknown;\n}',
   },
   {
     name: 'ProjectionDefinition',

+ 2 - 2
packages/session-projection/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-projection/session-projection-cache/README.md
-README.md: a10bb858159e9581c815d2532a893208c45e788a
-README.zh.md: 191efbc2c70875b20f3d5d2e873c87a3bd001eff
+README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e
+README.zh.md: ab4076df28cfe2b5a8b41d609967039dcacb7ef4

+ 2 - 2
packages/session-projection/session-projection-cache/README.md

@@ -4,10 +4,10 @@ English | [中文](README.zh.md)
 
 The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered 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).
 
-A stored row `(key → {stateVersion, observedSeq, state})` is a fold shortcut, never an authority: possibly stale (`observedSeq` says exactly how stale) but never wrong. Consequences the implementation commits to:
+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.
-- **`stateVersion` mismatch discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
+- **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.
 - **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.

+ 2 - 2
packages/session-projection/session-projection-cache/README.zh.md

@@ -4,10 +4,10 @@
 
 持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点(checkpoint),基于域数据形态(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 一节)。
 
-一条存储行 `(key → {stateVersion, observedSeq, state})` 是折叠捷径,绝不是权威:可能陈旧(`observedSeq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
+一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
 
 - **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部重放,绝不是错误的值。
-- **`stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
+- **`ver` 与活单元 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
 - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 契约的单元状态会大声失败。
 - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
 - **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部重放),绝不领先于它。

+ 3 - 3
packages/session-projection/session-projection-cache/src/index.ts

@@ -3,10 +3,10 @@
  * checkpoints of every registered 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 `observedSeq`
+ * 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
- * `stateVersion` mismatch discards the row instead of migrating it. Design
+ * `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
@@ -125,7 +125,7 @@ export class SessionProjectionCache extends Service {
     // The block carries ONE cut: the lowest served watermark is the seq every
     // value is at least current as of (under-claiming is safe under
     // higher-seq-wins; over-claiming would let a stale value outrank pushes).
-    const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { observedSeq: number }).observedSeq))
+    const asOfSeq = Math.min(...keys.map(key => (record.rows[key] as { seq: number }).seq))
     return { asOfSeq, values }
   }
 

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

@@ -16,7 +16,7 @@ export const inject = ['invariants']
 
 /**
  * No runtime invariant: the cache's correctness relation (a stored row equals
- * the registry fold at its `observedSeq`) is only checkable by re-running the
+ * 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

+ 12 - 11
packages/session-projection/session-projection-cache/src/spec.ts

@@ -1,7 +1,7 @@
 /**
  * The session-projcache domain declaration: one `sessions` table keyed by
  * {@link SessionId}, each record the full projection checkpoint for one
- * session (`key → {stateVersion, observedSeq, state}` rows). The spec object
+ * 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
@@ -14,17 +14,17 @@ 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, stateVersion,
- * observedSeq, state)` minus the two record keys). `state` is the unit's
- * internal state — plain JSON by the unit contract; `z.json()` enforces that
- * at the durable boundary. A row is never wrong, only possibly stale:
- * `observedSeq` says exactly how stale, and a `stateVersion` mismatch
+ * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)`
+ * minus the two record keys). `val` is the unit's internal state — plain
+ * JSON by the unit contract; `z.json()` enforces that at the durable
+ * boundary. A row is never wrong, only possibly stale: `seq` says exactly
+ * how stale, and a `ver` mismatch against the live unit's `stateVersion`
  * discards it at read time (never a migration).
  */
 export const checkpointRow = z.object({
-  stateVersion: z.number().int().nonnegative(),
-  observedSeq: z.number().int().gte(-1),
-  state: z.json(),
+  ver: z.number().int().nonnegative(),
+  seq: z.number().int().gte(-1),
+  val: z.json(),
 })
 
 /**
@@ -61,10 +61,11 @@ 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). v2 added the record's log-identity binding.
+ * never a wrong value). v2 added the record's log-identity binding; v3
+ * renamed the row fields to `ver`/`seq`/`val`.
  */
 export const projectionCacheDomainSpec = defineDomain({
   name: 'session_projcache',
-  version: 2,
+  version: 3,
   tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
 })

+ 17 - 17
packages/session-projection/session-projection-cache/tests/cache.spec.ts

@@ -100,7 +100,7 @@ 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, { stateVersion: number; observedSeq: number; state: unknown }>
+      rows: Record<string, { ver: number; seq: number; val: unknown }>
     } | undefined
 }
 
@@ -126,7 +126,7 @@ describe('SessionProjectionCache write policy', () => {
     const end = endTurn(session)
     await settle()
     const rows = storedRows(pool, session.id)
-    expect(rows?.['cache-test/marks']).toEqual({ stateVersion: 1, observedSeq: end.seq, state: { marks: ['a'] } })
+    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 () => {
@@ -140,7 +140,7 @@ describe('SessionProjectionCache write policy', () => {
     mark(session, ['live'])
     await owner.dispose()
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['live'] })
+    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['live'] })
   })
 
   it('flushes when the in-turn event count reaches the configured threshold', async () => {
@@ -152,7 +152,7 @@ describe('SessionProjectionCache write policy', () => {
     expect(storedRows(pool, session.id)).toBeUndefined()
     mark(session, ['3'])
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['3'] })
+    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['3'] })
   })
 
   it('flushes on the configured interval when the count threshold is not reached', async () => {
@@ -164,7 +164,7 @@ describe('SessionProjectionCache write policy', () => {
     expect(storedRows(pool, session.id)).toBeUndefined()
     await vi.advanceTimersByTimeAsync(1)
     await vi.advanceTimersByTimeAsync(0)
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['slow'] })
+    expect(storedRows(pool, 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 () => {
@@ -172,7 +172,7 @@ describe('SessionProjectionCache write policy', () => {
     // 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({ stateVersion: 1, observedSeq: -1, state: null })
+    expect(storedRows(pool, 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' as never,
@@ -214,7 +214,7 @@ describe('SessionProjectionCache write policy', () => {
     mark(session, ['y'])
     endTurn(session)
     await settle()
-    expect(storedRows(pool, session.id)?.['cache-test/marks']?.state).toEqual({ marks: ['y'] })
+    expect(storedRows(pool, session.id)?.['cache-test/marks']?.val).toEqual({ marks: ['y'] })
   })
 })
 
@@ -234,10 +234,10 @@ describe('SessionProjectionCache cold read', () => {
   function seedRow(
     pool: MemoryMediaPool,
     id: string,
-    row: { stateVersion: number; observedSeq: number; state: unknown },
+    row: { ver: number; seq: number; val: unknown },
     identity: { createdAt: number; cwd?: string } = { createdAt: 0 },
   ): void {
-    pool.versions.set('session_projcache', 2)
+    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,
@@ -248,7 +248,7 @@ describe('SessionProjectionCache cold read', () => {
     const pool = new MemoryMediaPool()
     const logs = new Map([['cold', storedLog([['a'], ['a', 'b']])]])
     // A warm-era checkpoint at watermark 1 (only ['a'] folded).
-    seedRow(pool, 'cold', { stateVersion: 1, observedSeq: 1, state: { marks: ['a'] } })
+    seedRow(pool, 'cold', { ver: 1, seq: 1, val: { marks: ['a'] } })
     const { cache, persistence, pool: samePool } = await harness({ pool, logs })
     const id = SessionId('cold')
     const snapshot = await cache.coldSnapshot(id)
@@ -258,13 +258,13 @@ describe('SessionProjectionCache cold read', () => {
     expect(persistence.readFrom).toHaveBeenCalledWith(id, 1, undefined)
     // Write-back: the stored row advanced to the served cut.
     expect(storedRows(samePool, id)?.['cache-test/marks'])
-      .toEqual({ stateVersion: 1, observedSeq: 3, state: { marks: ['a', 'b'] } })
+      .toEqual({ ver: 1, seq: 3, val: { marks: ['a', 'b'] } })
   })
 
   it('discards a version-mismatched row and refolds the full log', async () => {
     const pool = new MemoryMediaPool()
     const logs = new Map([['bumped', storedLog([['a']])]])
-    seedRow(pool, 'bumped', { stateVersion: 1, observedSeq: 2, state: { marks: ['stale'] } })
+    seedRow(pool, 'bumped', { ver: 1, seq: 2, val: { marks: ['stale'] } })
     const { cache, persistence } = await harness({ pool, logs, stateVersion: 2 })
     const snapshot = await cache.coldSnapshot(SessionId('bumped'))
     expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
@@ -276,7 +276,7 @@ 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 logs = new Map([['shrunk', storedLog([['a']])]]) // seqs 0..2
-    seedRow(pool, 'shrunk', { stateVersion: 1, observedSeq: 9, state: { marks: ['ghost'] } })
+    seedRow(pool, 'shrunk', { ver: 1, seq: 9, val: { marks: ['ghost'] } })
     const { cache, persistence } = await harness({ pool, logs })
     const snapshot = await cache.coldSnapshot(SessionId('shrunk'))
     expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['a'] })
@@ -307,7 +307,7 @@ describe('SessionProjectionCache cold read', () => {
     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', { stateVersion: 1, observedSeq: 2, state: { marks: ['phantom'] } }, { createdAt: 999 })
+    seedRow(pool, 'reborn', { ver: 1, seq: 2, val: { marks: ['phantom'] } }, { createdAt: 999 })
     const { cache, pool: samePool } = await harness({ pool, logs })
     const snapshot = await cache.coldSnapshot(SessionId('reborn'))
     expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
@@ -317,14 +317,14 @@ describe('SessionProjectionCache cold read', () => {
 
   it('cachedSnapshot returns undefined when every stored row is version-mismatched', async () => {
     const pool = new MemoryMediaPool()
-    seedRow(pool, 'all-stale', { stateVersion: 99, observedSeq: 4, state: { marks: ['old'] } })
+    seedRow(pool, 'all-stale', { ver: 99, seq: 4, val: { marks: ['old'] } })
     const { cache } = await harness({ pool })
     expect(cache.cachedSnapshot(headerOf(SessionId('all-stale')))).toBeUndefined()
   })
 
   it('binds identity on cwd too: a matching cwd serves, a moved session does not', async () => {
     const pool = new MemoryMediaPool()
-    seedRow(pool, 'homed', { stateVersion: 1, observedSeq: 2, state: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
+    seedRow(pool, 'homed', { ver: 1, seq: 2, val: { marks: ['w'] } }, { createdAt: 0, cwd: '/work' })
     const { cache } = await harness({ pool })
     const id = SessionId('homed')
     expect(cache.cachedSnapshot(headerOf(id, 0, '/work'))?.values['cache-test/marks']).toEqual({ marks: ['w'] })
@@ -352,7 +352,7 @@ describe('SessionProjectionCache cold read', () => {
 
   it('cachedSnapshot serves identity-matching rows with the cut watermark and refuses unrelated ones', async () => {
     const pool = new MemoryMediaPool()
-    seedRow(pool, 'listed', { stateVersion: 1, observedSeq: 4, state: { marks: ['t'] } })
+    seedRow(pool, 'listed', { ver: 1, seq: 4, val: { marks: ['t'] } })
     const { cache } = await harness({ pool })
     const id = SessionId('listed')
     // Matching header: values plus the watermark the client seeds under.

+ 2 - 2
packages/session-projection/session-projection/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-projection/session-projection/README.md
-README.md: 2e026aab55933c96ba961481f9597bc18cbbe910
-README.zh.md: a3e0b0f46466d19321b0950dc41d06473a54a1ce
+README.md: f4898b8e567fa5998c18111c5f4e27a8a350a42e
+README.zh.md: 385862868df495a5c857d91c32f6503c3ef72025

+ 2 - 2
packages/session-projection/session-projection/README.md

@@ -23,7 +23,7 @@ Session-projection seam. It owns `ctx.sessionProjections`, the registry that DRI
 - **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream.
 - **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
 - **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly.
-- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache (a later phase) stores `(sessionId, key, stateVersion, observedSeq, stateJson)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
+- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
 - **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them.
 - **Optional seam.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent.
 
@@ -43,5 +43,5 @@ None; projections never assemble or send provider requests.
 
 - **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large.
 - **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
-- **The persisted projection cache is a later phase** — cells live in memory only; a restart rebuilds by folding the in-memory log on first touch. The `stateVersion` field is the forward-declared invalidation anchor for that phase.
+- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
 - **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists.

+ 2 - 2
packages/session-projection/session-projection/README.zh.md

@@ -23,7 +23,7 @@
 - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
 - **单元的同步纪律。** `init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。
-- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache,后续阶段)存储 `(sessionId, key, stateVersion, observedSeq, stateJson)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
+- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
 - **可选 seam。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。
 
@@ -43,5 +43,5 @@
 
 - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
 - **正向驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。
-- **持久投影缓存属于后续阶段**——cell 目前只活在内存里;重启后首次触达时靠折叠内存日志重建。`stateVersion` 字段是为该阶段预先声明的失效锚点。
+- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
 - **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。

+ 32 - 33
packages/session-projection/session-projection/src/index.ts

@@ -66,9 +66,9 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
   view(state: S): SessionProjectionMap[K]
   /**
    * Persisted-cache invalidation anchor: bump whenever the state shape or the
-   * fold semantics change, so persisted `(sessionId, key, stateVersion,
-   * observedSeq, state)` rows from an older unit are discarded instead of
-   * being forward-applied into garbage. Non-negative integer.
+   * fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
+   * rows from an older unit are discarded instead of being forward-applied
+   * into garbage. Non-negative integer.
    */
   stateVersion: number
 }
@@ -99,20 +99,19 @@ export interface ProjectionSnapshot {
 
 /**
  * One unit's checkpoint: its internal state (plain JSON by the unit
- * contract), the seq of the last event folded into it, and the
+ * contract), the seq of the last event folded into it, and the unit
  * `stateVersion` that produced it — the persisted projection-cache row
- * `(sessionId, key, stateVersion, observedSeq, state)` minus the two outer
- * keys. A row is never authoritative, only a fold shortcut: `restore`
- * discards it on a `stateVersion` mismatch or when it claims events past the
- * stored log end.
+ * `(sessionId, key, ver, seq, val)` minus the two outer keys. A row is
+ * never authoritative, only a fold shortcut: `restore` discards it on a
+ * version mismatch or when it claims events past the stored log end.
  */
 export interface ProjectionCheckpointRow {
   /** The registering unit's `stateVersion` at fold time. */
-  stateVersion: number
-  /** Seq of the last event folded into `state`; -1 for the empty log. */
-  observedSeq: number
+  ver: number
+  /** Seq of the last event folded into `val`; -1 for the empty log. */
+  seq: number
   /** The unit's internal state — plain JSON per the unit contract. */
-  state: unknown
+  val: unknown
 }
 
 /** Checkpoint rows keyed by projection key (one session's persisted cache value). */
@@ -231,9 +230,9 @@ export class SessionProjectionRegistry extends Service {
    * State-level checkpoint of every registered unit for one session, read
    * from the watermark cache (missing cells fold lazily over the in-memory
    * log). This is the write side of the persisted projection cache: the
-   * returned rows are the `(key → {stateVersion, observedSeq, state})` part
-   * of the durable `(sessionId, key, stateVersion, observedSeq, state)`
-   * rows. Every `state` is a DETACHED structured clone — never the live
+   * returned rows are the `(key → {ver, seq, val})` part of the durable
+   * `(sessionId, key, ver, seq, val)`
+   * rows. Every `val` is a DETACHED structured clone — never the live
    * cell reference: the watermark cache is this registry's authoritative
    * mutable state, and a caller reaching the live reference could corrupt
    * every subsequent snapshot and frame through it (plain JSON by the unit
@@ -246,9 +245,9 @@ export class SessionProjectionRegistry extends Service {
     for (const registration of this.registrations.values()) {
       const cell = this.cellFor(registration, session)
       rows[registration.def.key] = {
-        stateVersion: registration.def.stateVersion,
-        observedSeq: cell.observedSeq,
-        state: structuredClone(cell.state),
+        ver: registration.def.stateVersion,
+        seq: cell.observedSeq,
+        val: structuredClone(cell.state),
       }
     }
     return rows
@@ -257,7 +256,7 @@ export class SessionProjectionRegistry extends Service {
   /**
    * The stored seq a {@link restore} tail read over `checkpoint` must start
    * at: one event BELOW the lowest usable watermark (a row is usable when
-   * its `stateVersion` matches the live unit; an absent or mismatched row
+   * its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
    * pulls the floor to `0` — that key must refold the full log). The
    * one-below anchor is load-bearing: the tail then proves how far the
    * stored log still extends, so {@link restore} can detect a log that
@@ -274,8 +273,8 @@ export class SessionProjectionRegistry extends Service {
     let floor: number | undefined
     for (const registration of this.registrations.values()) {
       const row = checkpoint[registration.def.key]
-      const need = row !== undefined && row.stateVersion === registration.def.stateVersion
-        ? Math.max(row.observedSeq + 1, 0)
+      const need = row !== undefined && row.ver === registration.def.stateVersion
+        ? Math.max(row.seq + 1, 0)
         : 0
       floor = floor === undefined ? need : Math.min(floor, need)
     }
@@ -284,7 +283,7 @@ export class SessionProjectionRegistry extends Service {
 
   /**
    * View a checkpoint's rows without any log read: for every registered
-   * unit whose row's `stateVersion` matches, serve the schema-validated
+   * unit whose row's `ver` matches, serve the schema-validated
    * `view` of the stored state; mismatched or absent rows leave their key
    * absent (a cold or listing consumer treats it as not-yet-available and a
    * fuller read path refolds it). The zero-I/O rung of the read ladder —
@@ -297,8 +296,8 @@ export class SessionProjectionRegistry extends Service {
     for (const registration of this.registrations.values()) {
       const def = registration.def
       const row = checkpoint[def.key]
-      if (row === undefined || row.stateVersion !== def.stateVersion) continue
-      values[def.key] = def.schema.parse(def.view(row.state))
+      if (row === undefined || row.ver !== def.stateVersion) continue
+      values[def.key] = def.schema.parse(def.view(row.val))
     }
     return values
   }
@@ -311,9 +310,9 @@ export class SessionProjectionRegistry extends Service {
    * `readFrom(id, restoreFloor(checkpoint))` and that same floor as
    * `baseSeq`; the floor's one-below anchor makes the supplied end honest,
    * so a shrunk log is detected here. A row is usable iff its
-   * `stateVersion` matches the live unit, it does not predate `baseSeq`
-   * (`observedSeq >= baseSeq - 1`), and it does not claim events past the
-   * supplied end (`observedSeq <= endSeq`); an unusable row is discarded
+   * `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
+   * (`seq >= baseSeq - 1`), and it does not claim events past the
+   * supplied end (`seq <= endSeq`); an unusable row is discarded
    * and its key refolds from `init` — which is only sound over the full
    * log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
    * from seq 0, e.g. after a crash-repair truncation shrank the log below
@@ -334,22 +333,22 @@ export class SessionProjectionRegistry extends Service {
       const def = registration.def
       const row = checkpoint[def.key]
       const usable = row !== undefined
-        && row.stateVersion === def.stateVersion
-        && row.observedSeq >= baseSeq - 1
-        && row.observedSeq <= endSeq
+        && row.ver === def.stateVersion
+        && row.seq >= baseSeq - 1
+        && row.seq <= endSeq
       if (!usable && baseSeq > 0) {
         throw new Error(
           `session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: `
           + 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0',
         )
       }
-      let state = usable ? row.state : def.init()
-      const from = usable ? row.observedSeq : baseSeq - 1
+      let state = usable ? row.val : def.init()
+      const from = usable ? row.seq : baseSeq - 1
       for (const event of events) {
         if (event.seq > from) state = def.apply(state, event)
       }
       values[def.key] = def.schema.parse(def.view(state))
-      refreshed[def.key] = { stateVersion: def.stateVersion, observedSeq: endSeq, state }
+      refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
     }
     return {
       snapshot: { asOfSeq: endSeq, values: values },

+ 24 - 24
packages/session-projection/session-projection/tests/registry.spec.ts

@@ -175,11 +175,11 @@ describe('SessionProjectionRegistry drive', () => {
     ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 })
     const markEvent = mark(session, ['a'])
     const rows = ctx.sessionProjections.checkpoint(session)
-    expect(rows['test/marks']).toEqual({ stateVersion: 1, observedSeq: markEvent.seq, state: { marks: ['a'] } })
-    expect(rows['test/count']).toEqual({ stateVersion: 7, observedSeq: markEvent.seq, state: 1 })
+    expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } })
+    expect(rows['test/count']).toEqual({ ver: 7, seq: markEvent.seq, val: 1 })
     // Empty log: init-derived state at watermark -1.
     const fresh = ctx.sessions.create()
-    expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null })
+    expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ ver: 1, seq: -1, val: null })
   })
 
   it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => {
@@ -188,11 +188,11 @@ describe('SessionProjectionRegistry drive', () => {
     mark(session, ['a'])
     const rows = ctx.sessionProjections.checkpoint(session)
     // Hostile (or merely careless) consumer mutates the handed-out state.
-    ;(rows['test/marks']?.state as { marks: string[] }).marks.push('INJECTED')
+    ;(rows['test/marks']?.val as { marks: string[] }).marks.push('INJECTED')
     // The registry's authoritative cell is untouched: snapshot and a fresh
     // checkpoint both still serve the committed value.
     expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] })
-    expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.state).toEqual({ marks: ['a'] })
+    expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.val).toEqual({ marks: ['a'] })
   })
 
   it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => {
@@ -204,18 +204,18 @@ describe('SessionProjectionRegistry drive', () => {
     // Lowest usable watermark is count's 5 → the anchored tail starts AT 5
     // (one below the first needed seq 6), so the read proves seq 5 still exists.
     expect(ctx.sessionProjections.restoreFloor({
-      'test/marks': { stateVersion: 1, observedSeq: 10, state: { marks: [] } },
-      'test/count': { stateVersion: 1, observedSeq: 5, state: 6 },
+      'test/marks': { ver: 1, seq: 10, val: { marks: [] } },
+      'test/count': { ver: 1, seq: 5, val: 6 },
     })).toBe(5)
     // A version-mismatched row forces that key back to a full refold.
     expect(ctx.sessionProjections.restoreFloor({
-      'test/marks': { stateVersion: 2, observedSeq: 10, state: { marks: [] } },
-      'test/count': { stateVersion: 1, observedSeq: 5, state: 6 },
+      'test/marks': { ver: 2, seq: 10, val: { marks: [] } },
+      'test/count': { ver: 1, seq: 5, val: 6 },
     })).toBe(0)
     // A fresh (-1) row still needs the whole tail from 0.
     expect(ctx.sessionProjections.restoreFloor({
-      'test/marks': { stateVersion: 1, observedSeq: -1, state: null },
-      'test/count': { stateVersion: 1, observedSeq: -1, state: 0 },
+      'test/marks': { ver: 1, seq: -1, val: null },
+      'test/count': { ver: 1, seq: -1, val: 0 },
     })).toBe(0)
   })
 
@@ -230,8 +230,8 @@ describe('SessionProjectionRegistry drive', () => {
     // marks row usable (watermark 2, tail starts at 3); count row mismatched — but
     // a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read.
     expect(() => ctx.sessionProjections.restore({
-      'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old'] } },
-      'test/count': { stateVersion: 99, observedSeq: 2, state: 3 },
+      'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } },
+      'test/count': { ver: 99, seq: 2, val: 3 },
     }, tail, 3)).toThrow(/re-read from seq 0/)
     // The full-log re-read (baseSeq 0) refolds the mismatched key from init.
     const full: SessionEvent[] = [
@@ -241,15 +241,15 @@ describe('SessionProjectionRegistry drive', () => {
       ...tail,
     ]
     const { snapshot, checkpoint } = ctx.sessionProjections.restore({
-      'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old', '2'] } },
-      'test/count': { stateVersion: 99, observedSeq: 2, state: 3 },
+      'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } },
+      'test/count': { ver: 99, seq: 2, val: 3 },
     }, full, 0)
     expect(snapshot.asOfSeq).toBe(4)
     expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] })
     expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events
     // The refreshed rows sit at the served cut, ready for a durable write-back.
-    expect(checkpoint['test/marks']).toEqual({ stateVersion: 1, observedSeq: 4, state: { marks: ['new'] } })
-    expect(checkpoint['test/count']).toEqual({ stateVersion: 1, observedSeq: 4, state: 5 })
+    expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } })
+    expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
   })
 
   it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => {
@@ -257,8 +257,8 @@ describe('SessionProjectionRegistry drive', () => {
     ctx.sessionProjections.register(marksUnit())
     ctx.sessionProjections.register(countUnit())
     const rows = {
-      'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } },
-      'test/count': { stateVersion: 1, observedSeq: 2, state: 3 },
+      'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
+      'test/count': { ver: 1, seq: 2, val: 3 },
     }
     const tail: SessionEvent[] = [
       { type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
@@ -273,8 +273,8 @@ describe('SessionProjectionRegistry drive', () => {
 
     // Empty tail (checkpoint is current): the cut sits at baseSeq - 1.
     const { snapshot: current } = ctx.sessionProjections.restore({
-      'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } },
-      'test/count': { stateVersion: 1, observedSeq: 4, state: 5 },
+      'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
+      'test/count': { ver: 1, seq: 4, val: 5 },
     }, [], 5)
     expect(current.asOfSeq).toBe(4)
     expect(current.values['test/count']).toBe(5)
@@ -285,8 +285,8 @@ describe('SessionProjectionRegistry drive', () => {
     ctx.sessionProjections.register(marksUnit())
     ctx.sessionProjections.register(countUnit())
     const values = ctx.sessionProjections.viewCheckpoint({
-      'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['stored'] } },
-      'test/count': { stateVersion: 99, observedSeq: 4, state: 5 }, // mismatched: absent
+      'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } },
+      'test/count': { ver: 99, seq: 4, val: 5 }, // mismatched: absent
     })
     expect(values['test/marks']).toEqual({ marks: ['stored'] })
     expect('test/count' in values).toBe(false)
@@ -296,7 +296,7 @@ describe('SessionProjectionRegistry drive', () => {
   it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => {
     const { ctx } = await harness()
     ctx.sessionProjections.register(countUnit())
-    const rows = { 'test/count': { stateVersion: 1, observedSeq: 9, state: 10 } }
+    const rows = { 'test/count': { ver: 1, seq: 9, val: 10 } }
     // The anchored floor sits ON the watermark, so the tail read must return
     // at least seq 9 from an intact log…
     const floor = ctx.sessionProjections.restoreFloor(rows)