Browse Source

refactor(session-projection): checkpoint every projection unit (migration side)

Adapts this branch to the base's removal of the persist opt-in: the
registry folds and checkpoints every registered unit, the host-only
subagent identity drops its explicit persist: true, and the
state-and-client-views note records the uniform rule. The cordis API
catalog and session-projection subsystem signatures are regenerated.
_Kerman 1 tháng trước cách đây
mục cha
commit
5ddbc6e71f

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # 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-session-projection-state-and-client-views.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md
-2026-08-19-session-projection-state-and-client-views.md: 928e2ce60c8371f8a1a695b6e9551d26314a55d1
-2026-08-19-session-projection-state-and-client-views.zh.md: 06daf00ee0aa275035991cda4df56b97bba227db
+2026-08-19-session-projection-state-and-client-views.md: 0b1810b27594e7f18af5afca67ba39b0cb0f3cc3
+2026-08-19-session-projection-state-and-client-views.zh.md: df6b18fab1a3b37bb11bc70bb86a625f76af2421

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md

@@ -12,7 +12,7 @@ The projection registry persisted each unit's internal fold state without a runt
 
 
 `SessionProjectionStateMap` is the merge-extensible table for host fold states. Every `ProjectionDefinition` key belongs to this table and supplies a `stateSchema`; cached rows are validated before they seed a fold. `SessionProjectionMap` retains its existing meaning and name as the sole table of client-visible whole values, preserving existing client data structures such as `title: string | null`.
 `SessionProjectionStateMap` is the merge-extensible table for host fold states. Every `ProjectionDefinition` key belongs to this table and supplies a `stateSchema`; cached rows are validated before they seed a fold. `SessionProjectionMap` retains its existing meaning and name as the sole table of client-visible whole values, preserving existing client data structures such as `title: string | null`.
 
 
-A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Client-visible units are always checkpointed. A host-only unit omits `wire` and is checkpointed only when `persist` is true. Carrier reads use `wireOnly` so internal states do not enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated.
+A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Carrier reads use `wireOnly` so internal states do not enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated.
 
 
 ## Consequences
 ## Consequences
 
 
@@ -24,5 +24,5 @@ The original [session-projection proposal](../../proposed/architecture/2026-07-2
 
 
 - **Rename the existing map to a state table and introduce a new client map** — rejected because it changes the established client type name and invites unnecessary client payload migrations.
 - **Rename the existing map to a state table and introduce a new client map** — rejected because it changes the established client type name and invites unnecessary client payload migrations.
 - **Keep one table for both state and client values** — rejected because a richer fold state and a compatibility-preserving client value then cannot be represented accurately.
 - **Keep one table for both state and client values** — rejected because a richer fold state and a compatibility-preserving client value then cannot be represented accurately.
-- **Persist every host-only unit** — rejected because persistence is a cold-read optimization with storage cost; an internal unit opts in only when its consumers need cold reconstruction.
+- **Opt-in persistence for host-only units** — rejected: a `persist` flag lets a unit silently skip the durable cache, and the savings (one small row per session) never justify the asymmetry or the stateVersion confusion it invites. Every unit's state is checkpointed uniformly.
 - **Return copied state from `stateOf`** — rejected because cloning every host read adds work without protecting a boundary; the method documents a readonly borrowed-reference obligation for typed same-process callers.
 - **Return copied state from `stateOf`** — rejected because cloning every host read adds work without protecting a boundary; the method documents a readonly borrowed-reference obligation for typed same-process callers.

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md

@@ -24,5 +24,5 @@
 
 
 - **把既有类型表改名为状态表,再引入新的客户端类型表**——不予采用,因为这会改变已经确立的客户端类型名称,并导致不必要的客户端载荷迁移。
 - **把既有类型表改名为状态表,再引入新的客户端类型表**——不予采用,因为这会改变已经确立的客户端类型名称,并导致不必要的客户端载荷迁移。
 - **继续用一张类型表同时描述状态与客户端值**——不予采用,因为这样无法准确表达更丰富的折叠状态和保持兼容的客户端值。
 - **继续用一张类型表同时描述状态与客户端值**——不予采用,因为这样无法准确表达更丰富的折叠状态和保持兼容的客户端值。
-- **持久化所有 host-only 单元**——不予采用,因为持久化是带存储成本的冷读优化;内部单元只有在消费方需要冷重建时才选择加入。
+- **host-only 单元按需选择持久化**——不予采用:`persist` 标志会让单元悄悄跳过持久化缓存,而省下的(每会话一行小记录)永远不值得这种不对称或它带来的 stateVersion 困惑。每个单元的状态统一写入检查点。
 - **让 `stateOf` 返回状态副本**——不予采用,因为每次 host 读取都克隆会增加工作,却没有保护任何边界;该方法为同进程类型化调用方明确规定只读借用引用义务。
 - **让 `stateOf` 返回状态副本**——不予采用,因为每次 host 读取都克隆会增加工作,却没有保护任何边界;该方法为同进程类型化调用方明确规定只读借用引用义务。

+ 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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
 #   pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
-session-projection.md: 3a354c081e38d23eb5ba337f3d4973788f1fe124
-session-projection.zh.md: 378b22faff90d15b3e34dc31112597a64a3a7b44
+session-projection.md: a0704206904880259425b6b32b648d383d815fb2
+session-projection.zh.md: 74392356d980e83b1ca281732b916f7d9e678702

+ 3 - 3
docs/subsystems/session-projection.md

@@ -100,7 +100,7 @@ type ProjectionChangeListener = (
 
 
 ## The registry: `ctx.sessionProjections`
 ## The registry: `ctx.sessionProjections`
 
 
-`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence. Unit contributors and host readers require the registry, so an incomplete composition fails during activation ([decision](../../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md)). Registrants of the same compatible definition share the unit until the last registration is disposed.
+`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence. Unit contributors and host readers require the registry, so an incomplete composition fails during activation ([decision](../../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)). Registrants of the same compatible definition share the unit until the last registration is disposed.
 
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
 
@@ -180,7 +180,7 @@ register< K extends keyof SessionProjectionMap, S extends SessionProjectionState
 
 
 /**
 /**
  * Register one host-only unit. Its state is omitted from client snapshots
  * Register one host-only unit. Its state is omitted from client snapshots
- * and persisted only when `persist` is true.
+ * and always checkpointed like every other unit.
  * @param definition - key, state schema, pure unit functions, and stateVersion.
  * @param definition - key, state schema, pure unit functions, and stateVersion.
  * @returns the exact disposer that unregisters this unit.
  * @returns the exact disposer that unregisters this unit.
  */
  */
@@ -289,5 +289,5 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base
 
 
 Types: [Session](session.md) · [SessionEvent](session.md)
 Types: [Session](session.md) · [SessionEvent](session.md)
 
 
-Source: [`packages/session/session-projection/src/index.ts:186`](../../packages/session/session-projection/src/index.ts)
+Source: [`packages/session/session-projection/src/index.ts:183`](../../packages/session/session-projection/src/index.ts)
 <!-- END GENERATED cordis-surface -->
 <!-- END GENERATED cordis-surface -->

+ 3 - 3
docs/subsystems/session-projection.zh.md

@@ -100,7 +100,7 @@ type ProjectionChangeListener = (
 
 
 ## 注册表:`ctx.sessionProjections`
 ## 注册表:`ctx.sessionProjections`
 
 
-`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失。单元贡献方与 host 读取方要求该注册表,因此不完整的组合会在激活时失败([决策](../../.agents/notes/implemented/architecture/2026-08-07-session-projection-mandatory-seam.md))。相同且兼容定义的多个注册者共享该单元,直到最后一项注册被 dispose。
+`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失。单元贡献方与 host 读取方要求该注册表,因此不完整的组合会在激活时失败([决策](../../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md))。相同且兼容定义的多个注册者共享该单元,直到最后一项注册被 dispose。
 
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
 
@@ -180,7 +180,7 @@ register< K extends keyof SessionProjectionMap, S extends SessionProjectionState
 
 
 /**
 /**
  * Register one host-only unit. Its state is omitted from client snapshots
  * Register one host-only unit. Its state is omitted from client snapshots
- * and persisted only when `persist` is true.
+ * and always checkpointed like every other unit.
  * @param definition - key, state schema, pure unit functions, and stateVersion.
  * @param definition - key, state schema, pure unit functions, and stateVersion.
  * @returns the exact disposer that unregisters this unit.
  * @returns the exact disposer that unregisters this unit.
  */
  */
@@ -289,5 +289,5 @@ restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], base
 
 
 Types: [Session](session.md) · [SessionEvent](session.md)
 Types: [Session](session.md) · [SessionEvent](session.md)
 
 
-Source: [`packages/session/session-projection/src/index.ts:186`](../../packages/session/session-projection/src/index.ts)
+Source: [`packages/session/session-projection/src/index.ts:183`](../../packages/session/session-projection/src/index.ts)
 <!-- END GENERATED cordis-surface -->
 <!-- END GENERATED cordis-surface -->

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

@@ -1120,7 +1120,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
       },
       {
       {
         signature: 'register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjectionMap>, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, \'wire\'>, ): () => void',
         signature: 'register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjectionMap>, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, \'wire\'>, ): () => void',
-        description: 'Register one host-only unit. Its state is omitted from client snapshots and persisted only when `persist` is true.',
+        description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit.',
         parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }],
         parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }],
         returns: 'the exact disposer that unregisters this unit.',
         returns: 'the exact disposer that unregisters this unit.',
       },
       },
@@ -3633,7 +3633,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   },
   {
   {
     name: 'ProjectionDefinition',
     name: 'ProjectionDefinition',
-    declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {\n    key: K;\n    stateSchema: ZodType<S>;\n    persist?: boolean;\n    init(): NoInfer<S>;\n    apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>;\n    wire?: K extends keyof SessionProjectionMap ? {\n        viewSchema: ZodType<SessionProjectionMap[K]>;\n        view(state: NoInfer<S>): SessionProjectionMap[K];\n    } : never;\n    stateVersion: number;\n}',
+    declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {\n    key: K;\n    stateSchema: ZodType<S>;\n    init(): NoInfer<S>;\n    apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>;\n    wire?: K extends keyof SessionProjectionMap ? {\n        viewSchema: ZodType<SessionProjectionMap[K]>;\n        view(state: NoInfer<S>): SessionProjectionMap[K];\n    } : never;\n    stateVersion: number;\n}',
   },
   },
   {
   {
     name: 'ProjectionSnapshot',
     name: 'ProjectionSnapshot',

+ 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;
 # 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:
 # 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
 #   pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md
-README.md: 51469918667a249e38167326d6ca33d2dda7d1e2
-README.zh.md: c701f2c044a330c8f5381aecdbffba12eacda305
+README.md: 8c2e3b788209fa51804b9203fa74f80c1142f82a
+README.zh.md: 2a0064ecad6a87154c6dd3e45cb5c7e9fb8d037c

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

@@ -2,7 +2,7 @@
 
 
 English | [中文](README.zh.md)
 English | [中文](README.zh.md)
 
 
-The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every client-visible or explicitly persisted projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
+The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every projection unit's state, one 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 → {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:
 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:
 
 

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

@@ -2,7 +2,7 @@
 
 
 [English](README.md) | 中文
 [English](README.md) | 中文
 
 
-持久投影缓存(`ctx.sessionProjectionCache`):把每个 client-visible 或显式选择持久化的投影单元状态保存为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。
+持久投影缓存(`ctx.sessionProjectionCache`):把每个投影单元的状态保存为检查点,基于域数据形态(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 → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
 
 

+ 0 - 1
packages/session/session-projection-cache/tests/cache.spec.ts

@@ -184,7 +184,6 @@ describe('SessionProjectionCache write policy', () => {
     ctx.sessionProjections.register({
     ctx.sessionProjections.register({
       key: 'cache-test/marks2',
       key: 'cache-test/marks2',
       stateSchema: z.custom<Map<string, string>>(() => true),
       stateSchema: z.custom<Map<string, string>>(() => true),
-      persist: true,
       init: () => new Map<string, string>(),
       init: () => new Map<string, string>(),
       apply: state => state,
       apply: state => state,
       stateVersion: 1,
       stateVersion: 1,

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
 #   pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: bbdb9fc172bc3be36a7594772eeef5111f922eaf
-README.zh.md: 058fa6da0b1d43a438552d2c554e9f968d50ebb6
+README.md: fe6acef7e0ed5a185ec719fd95335a77ba74f536
+README.zh.md: 5f77625cdbbbc7e3426a8de0cca96fa8c74f8685

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

@@ -17,7 +17,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr
 
 
 - `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
 - `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
 - `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here.
 - `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here.
-- `ProjectionDefinition<K, S>` — `{ key, stateSchema, init(), apply(state, event), wire?, persist?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only.
+- `ProjectionDefinition<K, S>` — `{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only.
 
 
 ## Contract
 ## Contract
 
 
@@ -25,7 +25,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr
 - **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.
 - **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).
 - **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`/`wire.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 `wire.viewSchema.parse`.
 - **Synchronous unit discipline.** `init`/`apply`/`wire.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 `wire.viewSchema.parse`.
-- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Client-visible units persist automatically; a host-only unit opts in with `persist: true`.
+- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike.
 - **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.
 - **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.
 - **Required for units and host reads.** Plugins that contribute or read projection units inject `sessionProjections`, so incomplete compositions fail during activation. The lower-level api-proxy factory remains tolerant for isolated tests and diagnostics and omits projection blocks and frames when the registry is absent.
 - **Required for units and host reads.** Plugins that contribute or read projection units inject `sessionProjections`, so incomplete compositions fail during activation. The lower-level api-proxy factory remains tolerant for isolated tests and diagnostics and omits projection blocks and frames when the registry is absent.
 
 

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

@@ -17,7 +17,7 @@
 
 
 - `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。
 - `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。
 - `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里。
 - `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里。
-- `ProjectionDefinition<K, S>`——`{ key, stateSchema, init(), apply(state, event), wire?, persist?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。
+- `ProjectionDefinition<K, S>`——`{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。
 
 
 ## 约定
 ## 约定
 
 
@@ -25,7 +25,7 @@
 - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
 - **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
 - **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
 - **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
 - **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
-- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。client-visible 单元自动持久化;host-only 单元以 `persist: true` 选择持久化。
+- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁。
 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
 - **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
 - **单元与 host 读取方必需。** 贡献或读取投影单元的插件注入 `sessionProjections`,因此不完整的组合会在激活时失败。较低层的 api-proxy factory 仍对隔离测试和诊断保持容错,注册表缺席时省略投影块与帧。
 - **单元与 host 读取方必需。** 贡献或读取投影单元的插件注入 `sessionProjections`,因此不完整的组合会在激活时失败。较低层的 api-proxy factory 仍对隔离测试和诊断保持容错,注册表缺席时省略投影块与帧。
 
 

+ 2 - 9
packages/session/session-projection/src/index.ts

@@ -47,8 +47,6 @@ export interface ProjectionDefinition<
   key: K
   key: K
   /** Validates persisted state before it seeds a fold. */
   /** Validates persisted state before it seeds a fold. */
   stateSchema: ZodType<S>
   stateSchema: ZodType<S>
-  /** Persist a host-only unit. Client-visible units are always persisted. */
-  persist?: boolean
   /**
   /**
    * State for the empty log.
    * State for the empty log.
    * @returns the initial state.
    * @returns the initial state.
@@ -139,7 +137,6 @@ interface ErasedDefinition {
   apply(state: unknown, event: SessionEvent): unknown
   apply(state: unknown, event: SessionEvent): unknown
   wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined
   wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined
   stateVersion: number
   stateVersion: number
-  persist: boolean
 }
 }
 
 
 /** Per-session per-unit watermark cache row. */
 /** Per-session per-unit watermark cache row. */
@@ -214,7 +211,7 @@ export class SessionProjectionRegistry extends Service {
   ): () => void
   ): () => void
   /**
   /**
    * Register one host-only unit. Its state is omitted from client snapshots
    * Register one host-only unit. Its state is omitted from client snapshots
-   * and persisted only when `persist` is true.
+   * and always checkpointed like every other unit.
    * @param definition - key, state schema, pure unit functions, and stateVersion.
    * @param definition - key, state schema, pure unit functions, and stateVersion.
    * @returns the exact disposer that unregisters this unit.
    * @returns the exact disposer that unregisters this unit.
    */
    */
@@ -240,7 +237,6 @@ export class SessionProjectionRegistry extends Service {
         ? undefined
         ? undefined
         : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) },
         : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) },
       stateVersion: definition.stateVersion,
       stateVersion: definition.stateVersion,
-      persist: wire !== undefined || definition.persist === true,
     }
     }
     if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) {
     if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) {
       throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`)
       throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`)
@@ -341,7 +337,6 @@ export class SessionProjectionRegistry extends Service {
   checkpoint(session: Session): ProjectionCheckpoint {
   checkpoint(session: Session): ProjectionCheckpoint {
     const rows: ProjectionCheckpoint = {}
     const rows: ProjectionCheckpoint = {}
     for (const registration of this.registrations.values()) {
     for (const registration of this.registrations.values()) {
-      if (!registration.def.persist) continue
       const cell = this.cellFor(registration, session)
       const cell = this.cellFor(registration, session)
       rows[registration.def.key] = {
       rows[registration.def.key] = {
         ver: registration.def.stateVersion,
         ver: registration.def.stateVersion,
@@ -371,7 +366,6 @@ export class SessionProjectionRegistry extends Service {
   restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined {
   restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined {
     let floor: number | undefined
     let floor: number | undefined
     for (const registration of this.registrations.values()) {
     for (const registration of this.registrations.values()) {
-      if (!registration.def.persist) continue
       const row = checkpoint[registration.def.key]
       const row = checkpoint[registration.def.key]
       const need = row !== undefined && row.ver === registration.def.stateVersion
       const need = row !== undefined && row.ver === registration.def.stateVersion
         ? Math.max(row.seq + 1, 0)
         ? Math.max(row.seq + 1, 0)
@@ -399,7 +393,7 @@ export class SessionProjectionRegistry extends Service {
     const values: Record<string, unknown> = {}
     const values: Record<string, unknown> = {}
     for (const registration of this.registrations.values()) {
     for (const registration of this.registrations.values()) {
       const def = registration.def
       const def = registration.def
-      if (!def.persist || (options?.wireOnly === true && def.wire === undefined)) continue
+      if (options?.wireOnly === true && def.wire === undefined) continue
       const row = checkpoint[def.key]
       const row = checkpoint[def.key]
       if (row === undefined || row.ver !== def.stateVersion) continue
       if (row === undefined || row.ver !== def.stateVersion) continue
       let state: unknown
       let state: unknown
@@ -450,7 +444,6 @@ export class SessionProjectionRegistry extends Service {
     const refreshed: ProjectionCheckpoint = {}
     const refreshed: ProjectionCheckpoint = {}
     for (const registration of this.registrations.values()) {
     for (const registration of this.registrations.values()) {
       const def = registration.def
       const def = registration.def
-      if (!def.persist) continue
       const row = checkpoint[def.key]
       const row = checkpoint[def.key]
       const usable = row !== undefined
       const usable = row !== undefined
         && row.ver === def.stateVersion
         && row.ver === def.stateVersion

+ 0 - 1
packages/session/session-projection/tests/registry.spec.ts

@@ -51,7 +51,6 @@ const marksUnit = (): ProjectionDefinition<'test/marks', MarksState>
 const countUnit = (): ProjectionDefinition<'test/count', number> => ({
 const countUnit = (): ProjectionDefinition<'test/count', number> => ({
   key: 'test/count',
   key: 'test/count',
   stateSchema: z.number().int().nonnegative(),
   stateSchema: z.number().int().nonnegative(),
-  persist: true,
   init: () => 0,
   init: () => 0,
   apply: state => state + 1,
   apply: state => state + 1,
   stateVersion: 1,
   stateVersion: 1,

+ 0 - 1
packages/subagent/subagent/src/projection.ts

@@ -153,7 +153,6 @@ export const subagentIdentityProjectionDefinition = {
   key: 'subagent',
   key: 'subagent',
   stateVersion: 3,
   stateVersion: 3,
   stateSchema: identitySchema,
   stateSchema: identitySchema,
-  persist: true,
   init: () => null,
   init: () => null,
   apply: (state, event) => {
   apply: (state, event) => {
     if (event.type !== 'subagent/descriptor') return state
     if (event.type !== 'subagent/descriptor') return state