Parcourir la source

fix(session-projection): keep host state off wire

_Kerman il y a 1 mois
Parent
commit
9127d7e8b7
30 fichiers modifiés avec 227 ajouts et 314 suppressions
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md
  4. 2 2
      docs/subsystems/plan.i18n.yaml
  5. 1 1
      docs/subsystems/plan.md
  6. 1 1
      docs/subsystems/plan.zh.md
  7. 2 2
      docs/subsystems/session-projection.i18n.yaml
  8. 18 22
      docs/subsystems/session-projection.md
  9. 18 22
      docs/subsystems/session-projection.zh.md
  10. 17 21
      packages/extensions/tool-cordis/src/api-catalog.ts
  11. 4 4
      packages/host/apiproxy/src/api-proxy.ts
  12. 36 0
      packages/host/apiproxy/tests/api-proxy-projections.spec.ts
  13. 1 1
      packages/interaction/permission-presets/src/index.ts
  14. 1 5
      packages/llm/token-meter/src/breakdown-projection.ts
  15. 3 3
      packages/llm/token-meter/src/projection.ts
  16. 6 7
      packages/llm/token-meter/src/usage-projection.ts
  17. 7 4
      packages/plan/plan-mode/src/index.ts
  18. 2 2
      packages/session/session-projection-cache/README.i18n.yaml
  19. 2 2
      packages/session/session-projection-cache/README.md
  20. 2 2
      packages/session/session-projection-cache/README.zh.md
  21. 5 10
      packages/session/session-projection-cache/src/index.ts
  22. 13 0
      packages/session/session-projection-cache/tests/cache.spec.ts
  23. 2 2
      packages/session/session-projection/README.i18n.yaml
  24. 2 2
      packages/session/session-projection/README.md
  25. 2 2
      packages/session/session-projection/README.zh.md
  26. 35 75
      packages/session/session-projection/src/index.ts
  27. 4 4
      packages/session/session-projection/src/types.ts
  28. 30 105
      packages/session/session-projection/tests/registry.spec.ts
  29. 7 8
      packages/subagent/subagent/src/projection.ts
  30. 0 1
      scripts/gen-cordis-catalog.ts

+ 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;
 # 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
-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: fea5474c78f04cc66aa317d69e72b9fcde6bfe9e
+2026-08-19-session-projection-state-and-client-views.zh.md: 5ec82ed44576be209259308d406bde2a5ebf6825

+ 1 - 1
.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`.
 
-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`. Client-visible units are always checkpointed. A host-only unit omits `wire` and is checkpointed only when `persist` is true. Snapshot APIs return only `SessionProjectionMap`, so internal states cannot enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated.
 
 ## Consequences
 

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

@@ -12,7 +12,7 @@
 
 `SessionProjectionStateMap` 是 host 折叠状态的 merge-extensible 类型表。每个 `ProjectionDefinition` key 都属于此表并提供 `stateSchema`;缓存行只有通过校验后才能为折叠提供初始状态。`SessionProjectionMap` 保留原有名称和语义,继续作为唯一的客户端可见全量值类型表,因此 `title: string | null` 等既有客户端数据结构保持不变。
 
-如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。客户端可见单元始终写入检查点。host-only 单元省略 `wire`,且仅在 `persist` 为 true 时写入检查点。载体读取使用 `wireOnly`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。
+如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。客户端可见单元始终写入检查点。host-only 单元省略 `wire`,且仅在 `persist` 为 true 时写入检查点。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。
 
 ## 结果
 

+ 2 - 2
docs/subsystems/plan.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/plan.md
-plan.md: 11abe27f2bfa0f2d32bd9d37600792ba915d63fb
-plan.zh.md: 47fa160beb4651850a44b325edb6700b116855a4
+plan.md: 9de3566e9f065ed537e9ad97285a22d0bf8fac14
+plan.zh.md: 825065c79070398daa2f42216d1537010b45e31f

+ 1 - 1
docs/subsystems/plan.md

@@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
 
 Types: [Agent](core.md)
 
-Source: [`packages/plan/plan-mode/src/index.ts:199`](../../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:202`](../../packages/plan/plan-mode/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 1 - 1
docs/subsystems/plan.zh.md

@@ -83,5 +83,5 @@ set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop
 
 Types: [Agent](core.md)
 
-Source: [`packages/plan/plan-mode/src/index.ts:199`](../../packages/plan/plan-mode/src/index.ts)
+Source: [`packages/plan/plan-mode/src/index.ts:202`](../../packages/plan/plan-mode/src/index.ts)
 <!-- END GENERATED cordis-surface -->

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

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

+ 18 - 22
docs/subsystems/session-projection.md

@@ -70,15 +70,15 @@ The whole-value event rule is load-bearing: a state-carrying log event carries t
 
 ```ts type-equiv
 /**
- * One consistent read cut over every registered unit for one session.
+ * One consistent read cut over every registered client-visible unit for one session.
  * `asOfSeq` is the shared watermark — the seq of the last event every value
  * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`).
  */
 interface ProjectionSnapshot {
   /** Seq of the last event the values reflect; -1 for an empty log. */
   asOfSeq: number
-  /** Whole current value per registered key. */
-  values: Partial<ProjectionValues>
+  /** Whole current client value per registered key. */
+  values: Partial<SessionProjectionMap>
 }
 ```
 
@@ -96,7 +96,7 @@ type ProjectionChangeListener = (
 ) => void
 ```
 
-`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. By default it returns client views and host-only states; carriers pass `{ wireOnly: true }`. Every client value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change.
+`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. The change feed fires once per client-visible unit whose state *reference* changed for each committed event; `apply` must return the same reference when its state did not change.
 
 ## The registry: `ctx.sessionProjections`
 
@@ -126,11 +126,10 @@ The persisted projection cache service. Opens the `session_projcache` domain at
  * paths (the history tail baseline, {@link coldSnapshot}) supersede these
  * values whenever a session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
- * @param options - restrict the result to client-visible keys.
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
  *   `undefined` when no usable row exists for this lifecycle.
  */
-cachedSnapshot( meta: SessionHeader, options?: { wireOnly?: boolean }, ): ProjectionSnapshot | undefined
+cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
 
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
@@ -165,7 +164,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack
 
 ### `ctx.sessionProjections` — `SessionProjectionRegistry`
 
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. 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 (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. 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 (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
 
 ```ts cordis-catalog
 /**
@@ -176,7 +175,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack
  * @param definition - key, state schema, pure unit functions, and stateVersion.
  * @returns the exact disposer that unregisters this unit.
  */
-register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: ProjectionDefinition<K, S> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): () => void
+register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire' | 'persist'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> persist?: true }, ): () => void
 
 /**
  * Register one host-only unit. Its state is omitted from client snapshots
@@ -189,7 +188,7 @@ register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjec
 /**
  * Subscribe to the change feed. The registration is an effect on the
  * calling context's fiber.
- * @param listener - called once per unit whose state reference changed, per committed event.
+ * @param listener - called once per client-visible unit whose state reference changed, per committed event.
  * @returns the exact disposer that unsubscribes.
  */
 onChanged(listener: ProjectionChangeListener): () => void
@@ -204,15 +203,14 @@ onChanged(listener: ProjectionChangeListener): () => void
 stateOf<K extends keyof SessionProjectionStateMap>( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined
 
 /**
- * One consistent cut over every registered unit for one session, read from
+ * One consistent cut over every registered client-visible unit for one session, read from
  * the watermark cache (missing cells fold lazily over the in-memory log).
  * Fully synchronous — every value and `asOfSeq` reflect the same log
- * position. Each value passes its unit's schema before leaving.
+ * position. Each value passes its unit's `viewSchema` before leaving.
  * @param session - the session whose projection values are read.
- * @param options - restrict the result to client-visible keys.
- * @returns the snapshot; `values` is empty when no unit is registered.
+ * @returns the snapshot; `values` is empty when no client-visible unit is registered.
  */
-snapshot(session: Session, options?: { wireOnly?: boolean }): ProjectionSnapshot
+snapshot(session: Session): ProjectionSnapshot
 
 /**
  * State-level checkpoint of every persisted unit for one session, read
@@ -226,7 +224,7 @@ snapshot(session: Session, options?: { wireOnly?: boolean }): ProjectionSnapshot
  * every subsequent snapshot and frame through it (plain JSON by the unit
  * contract, so the clone is total).
  * @param session - the session whose unit states are checkpointed.
- * @returns one row per registered key; empty when no unit is registered.
+ * @returns one row per persisted key; empty when no persisted unit is registered.
  */
 checkpoint(session: Session): ProjectionCheckpoint
 
@@ -243,23 +241,22 @@ checkpoint(session: Session): ProjectionCheckpoint
  * re-read.
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
  * @returns the seq to hand the persistence `readFrom`, or `undefined`
- *   when no unit is registered (no read needed — {@link restore} would
+ *   when no persisted unit is registered (no read needed — {@link restore} would
  *   serve empty values regardless).
  */
 restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
 
 /**
  * View a checkpoint's rows without any log read: for every registered
- * unit whose row's `ver` matches, serve the schema-validated
+ * client-visible unit whose row's `ver` matches, serve the schema-validated
  * `view` of the schema-validated stored state; mismatched, malformed, 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 —
  * values are as stale as their rows, never wrong.
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
- * @param options - restrict the result to client-visible keys.
  * @returns whole values per key with a usable row; empty when none.
  */
-viewCheckpoint( checkpoint: ProjectionCheckpoint, options?: { wireOnly?: boolean }, ): Partial<ProjectionValues>
+viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
 
 /**
  * Cold read: fold every persisted unit over a stored log suffix, seeding
@@ -279,15 +276,14 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, options?: { wireOnly?: boolean
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
  * @param events - the stored events with `seq >= baseSeq`, in seq order.
  * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
- * @param options - restrict returned values to client-visible keys.
  * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
  *   supplied event's seq, `baseSeq - 1` for an empty tail) plus the
  *   refreshed checkpoint rows at that cut, ready for a durable write-back.
  */
-restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, options?: { wireOnly?: boolean }, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
+restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
 ```
 
 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 -->

+ 18 - 22
docs/subsystems/session-projection.zh.md

@@ -70,15 +70,15 @@ interface ProjectionDefinition<
 
 ```ts type-equiv
 /**
- * One consistent read cut over every registered unit for one session.
+ * One consistent read cut over every registered client-visible unit for one session.
  * `asOfSeq` is the shared watermark — the seq of the last event every value
  * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`).
  */
 interface ProjectionSnapshot {
   /** Seq of the last event the values reflect; -1 for an empty log. */
   asOfSeq: number
-  /** Whole current value per registered key. */
-  values: Partial<ProjectionValues>
+  /** Whole current client value per registered key. */
+  values: Partial<SessionProjectionMap>
 }
 ```
 
@@ -96,7 +96,7 @@ type ProjectionChangeListener = (
 ) => void
 ```
 
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。默认结果包含客户端视图与 host-only 状态;载体传入 `{ wireOnly: true }`。每个客户端值在返回前都会通过其单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次;状态未变时,`apply` 必须返回同一引用。
 
 ## 注册表:`ctx.sessionProjections`
 
@@ -126,11 +126,10 @@ The persisted projection cache service. Opens the `session_projcache` domain at
  * paths (the history tail baseline, {@link coldSnapshot}) supersede these
  * values whenever a session is actually opened.
  * @param meta - the listed session's header (identity witness; no log read).
- * @param options - restrict the result to client-visible keys.
  * @returns the cut (`asOfSeq` = lowest served-row watermark), or
  *   `undefined` when no usable row exists for this lifecycle.
  */
-cachedSnapshot( meta: SessionHeader, options?: { wireOnly?: boolean }, ): ProjectionSnapshot | undefined
+cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined
 
 /**
  * Durably checkpoint one live session NOW (both mandatory points call
@@ -165,7 +164,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack
 
 ### `ctx.sessionProjections` — `SessionProjectionRegistry`
 
-`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. 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 (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
+`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. 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 (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.
 
 ```ts cordis-catalog
 /**
@@ -176,7 +175,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts:71`](../../pack
  * @param definition - key, state schema, pure unit functions, and stateVersion.
  * @returns the exact disposer that unregisters this unit.
  */
-register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: ProjectionDefinition<K, S> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> }, ): () => void
+register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, 'wire' | 'persist'> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> persist?: true }, ): () => void
 
 /**
  * Register one host-only unit. Its state is omitted from client snapshots
@@ -189,7 +188,7 @@ register< K extends Exclude<keyof SessionProjectionStateMap, keyof SessionProjec
 /**
  * Subscribe to the change feed. The registration is an effect on the
  * calling context's fiber.
- * @param listener - called once per unit whose state reference changed, per committed event.
+ * @param listener - called once per client-visible unit whose state reference changed, per committed event.
  * @returns the exact disposer that unsubscribes.
  */
 onChanged(listener: ProjectionChangeListener): () => void
@@ -204,15 +203,14 @@ onChanged(listener: ProjectionChangeListener): () => void
 stateOf<K extends keyof SessionProjectionStateMap>( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined
 
 /**
- * One consistent cut over every registered unit for one session, read from
+ * One consistent cut over every registered client-visible unit for one session, read from
  * the watermark cache (missing cells fold lazily over the in-memory log).
  * Fully synchronous — every value and `asOfSeq` reflect the same log
- * position. Each value passes its unit's schema before leaving.
+ * position. Each value passes its unit's `viewSchema` before leaving.
  * @param session - the session whose projection values are read.
- * @param options - restrict the result to client-visible keys.
- * @returns the snapshot; `values` is empty when no unit is registered.
+ * @returns the snapshot; `values` is empty when no client-visible unit is registered.
  */
-snapshot(session: Session, options?: { wireOnly?: boolean }): ProjectionSnapshot
+snapshot(session: Session): ProjectionSnapshot
 
 /**
  * State-level checkpoint of every persisted unit for one session, read
@@ -226,7 +224,7 @@ snapshot(session: Session, options?: { wireOnly?: boolean }): ProjectionSnapshot
  * every subsequent snapshot and frame through it (plain JSON by the unit
  * contract, so the clone is total).
  * @param session - the session whose unit states are checkpointed.
- * @returns one row per registered key; empty when no unit is registered.
+ * @returns one row per persisted key; empty when no persisted unit is registered.
  */
 checkpoint(session: Session): ProjectionCheckpoint
 
@@ -243,23 +241,22 @@ checkpoint(session: Session): ProjectionCheckpoint
  * re-read.
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
  * @returns the seq to hand the persistence `readFrom`, or `undefined`
- *   when no unit is registered (no read needed — {@link restore} would
+ *   when no persisted unit is registered (no read needed — {@link restore} would
  *   serve empty values regardless).
  */
 restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined
 
 /**
  * View a checkpoint's rows without any log read: for every registered
- * unit whose row's `ver` matches, serve the schema-validated
+ * client-visible unit whose row's `ver` matches, serve the schema-validated
  * `view` of the schema-validated stored state; mismatched, malformed, 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 —
  * values are as stale as their rows, never wrong.
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
- * @param options - restrict the result to client-visible keys.
  * @returns whole values per key with a usable row; empty when none.
  */
-viewCheckpoint( checkpoint: ProjectionCheckpoint, options?: { wireOnly?: boolean }, ): Partial<ProjectionValues>
+viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
 
 /**
  * Cold read: fold every persisted unit over a stored log suffix, seeding
@@ -279,15 +276,14 @@ viewCheckpoint( checkpoint: ProjectionCheckpoint, options?: { wireOnly?: boolean
  * @param checkpoint - persisted rows for one session (possibly stale or empty).
  * @param events - the stored events with `seq >= baseSeq`, in seq order.
  * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
- * @param options - restrict returned values to client-visible keys.
  * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
  *   supplied event's seq, `baseSeq - 1` for an empty tail) plus the
  *   refreshed checkpoint rows at that cut, ready for a durable write-back.
  */
-restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, options?: { wireOnly?: boolean }, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
+restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
 ```
 
 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 -->

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

@@ -1088,9 +1088,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     description: 'The persisted projection cache service. Opens the `session_projcache` domain at init, checkpoints live sessions on a throttled write-behind (count/interval triggers from Config) plus two mandatory points — `turn/end` and session disposal (the live-to-cold moment) — and serves the cold-read ladder: cached row, persistence `readFrom` tail, registry `restore`, durable write-back. Every durable write is fail-soft: failures log a warning and the cache self-heals on the next write or cold read.',
     methods: [
       {
-        signature: 'cachedSnapshot( meta: SessionHeader, options?: { wireOnly?: boolean }, ): ProjectionSnapshot | undefined',
+        signature: 'cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined',
         description: 'The zero-I/O listing read: whole values viewed straight from the stored rows (version-matching keys only), each cut carried with its watermark so a client value store can seed under its higher-seq-wins rule — as stale as the last durable checkpoint but never wrong, and never from an unrelated log (the caller\'s header is the identity witness). Fresher paths (the history tail baseline, coldSnapshot) supersede these values whenever a session is actually opened.',
-        parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }, { name: 'options', description: 'restrict the result to client-visible keys.' }],
+        parameters: [{ name: 'meta', description: 'the listed session\'s header (identity witness; no log read).' }],
         returns: 'the cut (`asOfSeq` = lowest served-row watermark), or `undefined` when no usable row exists for this lifecycle.',
       },
       {
@@ -1110,10 +1110,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   {
     key: 'sessionProjections',
     summary: '`ctx.sessionProjections`: the projection unit table and its drive.',
-    description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference notifies the change feed with the schema-validated view. 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 (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
+    description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive), and a changed state reference in a client-visible unit notifies the change feed with the schema-validated view. 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 (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. Domain plugins register under `ctx.inject([\'sessionProjections\'], …)` so headless assemblies without the registry stay unaffected. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.',
     methods: [
       {
-        signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: ProjectionDefinition<K, S> & { wire: NonNullable<ProjectionDefinition<K, S>[\'wire\']> }, ): () => void',
+        signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit<ProjectionDefinition<K, S>, \'wire\' | \'persist\'> & { wire: NonNullable<ProjectionDefinition<K, S>[\'wire\']> persist?: true }, ): () => void',
         description: 'Register one domain\'s unit. The registration is an effect on the calling context\'s fiber: disposing the fiber (or calling the returned disposer) removes the key — and the unit\'s cached cells — from subsequent drives and snapshots.',
         parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }],
         returns: 'the exact disposer that unregisters this unit.',
@@ -1127,7 +1127,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       {
         signature: 'onChanged(listener: ProjectionChangeListener): () => void',
         description: 'Subscribe to the change feed. The registration is an effect on the calling context\'s fiber.',
-        parameters: [{ name: 'listener', description: 'called once per unit whose state reference changed, per committed event.' }],
+        parameters: [{ name: 'listener', description: 'called once per client-visible unit whose state reference changed, per committed event.' }],
         returns: 'the exact disposer that unsubscribes.',
       },
       {
@@ -1137,33 +1137,33 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'current state, or `undefined` when the key is not registered.',
       },
       {
-        signature: 'snapshot(session: Session, options?: { wireOnly?: boolean }): ProjectionSnapshot',
-        description: 'One consistent cut over every registered unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s schema before leaving.',
-        parameters: [{ name: 'session', description: 'the session whose projection values are read.' }, { name: 'options', description: 'restrict the result to client-visible keys.' }],
-        returns: 'the snapshot; `values` is empty when no unit is registered.',
+        signature: 'snapshot(session: Session): ProjectionSnapshot',
+        description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.',
+        parameters: [{ name: 'session', description: 'the session whose projection values are read.' }],
+        returns: 'the snapshot; `values` is empty when no client-visible unit is registered.',
       },
       {
         signature: 'checkpoint(session: Session): ProjectionCheckpoint',
         description: 'State-level checkpoint of every persisted 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 → {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 contract, so the clone is total).',
         parameters: [{ name: 'session', description: 'the session whose unit states are checkpointed.' }],
-        returns: 'one row per registered key; empty when no unit is registered.',
+        returns: 'one row per persisted key; empty when no persisted unit is registered.',
       },
       {
         signature: 'restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined',
         description: 'The stored seq a restore tail read over `checkpoint` must start at: one event BELOW the lowest usable watermark (a row is usable when 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 restore can detect a log that shrank below a row\'s watermark (crash-repair truncation) instead of serving the stale row as current — an empty tail read from the anchor yields an end below every watermark and the restore rejects for a full re-read.',
         parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }],
-        returns: 'the seq to hand the persistence `readFrom`, or `undefined` when no unit is registered (no read needed — {@link restore} would serve empty values regardless).',
+        returns: 'the seq to hand the persistence `readFrom`, or `undefined` when no persisted unit is registered (no read needed — {@link restore} would serve empty values regardless).',
       },
       {
-        signature: 'viewCheckpoint( checkpoint: ProjectionCheckpoint, options?: { wireOnly?: boolean }, ): Partial<ProjectionValues>',
-        description: 'View a checkpoint\'s rows without any log read: for every registered unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, 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 — values are as stale as their rows, never wrong.',
-        parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'options', description: 'restrict the result to client-visible keys.' }],
+        signature: 'viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>',
+        description: 'View a checkpoint\'s rows without any log read: for every registered client-visible unit whose row\'s `ver` matches, serve the schema-validated `view` of the schema-validated stored state; mismatched, malformed, 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 — values are as stale as their rows, never wrong.',
+        parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }],
         returns: 'whole values per key with a usable row; empty when none.',
       },
       {
-        signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, options?: { wireOnly?: boolean }, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
+        signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
         description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the events returned by a persistence `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 `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 a row\'s watermark).',
-        parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'options', description: 'restrict returned values to client-visible keys.' }],
+        parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }],
         returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.',
       },
     ],
@@ -3637,11 +3637,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ProjectionSnapshot',
-    declaration: 'export interface ProjectionSnapshot {\n    asOfSeq: number;\n    values: Partial<ProjectionValues>;\n}',
-  },
-  {
-    name: 'ProjectionValues',
-    declaration: 'export type ProjectionValues = {\n    [K in keyof SessionProjectionStateMap]: K extends keyof SessionProjectionMap ? SessionProjectionMap[K] : SessionProjectionStateMap[K];\n};',
+    declaration: 'export interface ProjectionSnapshot {\n    asOfSeq: number;\n    values: Partial<SessionProjectionMap>;\n}',
   },
   {
     name: 'PromptAssembly',

+ 4 - 4
packages/host/apiproxy/src/api-proxy.ts

@@ -786,7 +786,7 @@ type HistorySource =
 function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
   const registry = ctx.get('sessionProjections')
   if (registry === undefined) return undefined
-  return registry.snapshot(session, { wireOnly: true })
+  return registry.snapshot(session)
 }
 
 /**
@@ -802,8 +802,8 @@ function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock
 function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
   try {
     const block = session !== undefined
-      ? ctx.get('sessionProjections')?.snapshot(session, { wireOnly: true })
-      : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta, { wireOnly: true })
+      ? ctx.get('sessionProjections')?.snapshot(session)
+      : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
     return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
   } catch (error) {
     ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
@@ -818,7 +818,7 @@ function detachedProjectionsFor(
 ): SessionProjectionsBlock | undefined {
   const registry = ctx.get('sessionProjections')
   if (registry === undefined) return undefined
-  return registry.restore({}, events, 0, { wireOnly: true }).snapshot
+  return registry.restore({}, events, 0).snapshot
 }
 
 /**

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

@@ -26,6 +26,7 @@ import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
 declare module '@deepseek-ai/dsh-session-projection/types' {
   interface SessionProjectionStateMap {
     'test/last-user': LastUserState
+    'test/internal-count': number
   }
   interface SessionProjectionMap {
     'test/last-user': { text: string } | null
@@ -53,6 +54,14 @@ const lastUserUnit = () => ({
   stateVersion: 1,
 }) satisfies ProjectionDefinition<'test/last-user', LastUserState>
 
+const internalCountUnit = () => ({
+  key: 'test/internal-count',
+  stateSchema: z.number().int().nonnegative(),
+  init: () => 0,
+  apply: (state: number) => state + 1,
+  stateVersion: 1,
+}) satisfies ProjectionDefinition<'test/internal-count', number>
+
 async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> {
   const ctx = new Context()
   await ctx.plugin(SessionStore)
@@ -158,6 +167,33 @@ describe('session.history projections block', () => {
     expect('projections' in response.result.value).toBe(false)
   })
 
+  it('never exposes a host-only unit through history, listing, or push frames', async () => {
+    const { ctx, session } = await harness(true)
+    ctx.sessionProjections.register(internalCountUnit())
+    const proxy = api(ctx)
+    await new Promise(resolve => setTimeout(resolve, 0))
+    const abort = new AbortController()
+    const frames: MuxFrame[] = []
+    const drained = (async () => {
+      for await (const envelope of proxy.events.mux({ rpcId: RpcId('t-host-only-mux'), payload: {} }, abort.signal)) {
+        frames.push(envelope.payload)
+        if (envelope.payload.type === 'session/event') abort.abort()
+      }
+    })().catch(() => {})
+
+    seedMessages(session, 1)
+    await drained
+
+    const history = await proxy.sessions.history(request({ sessionId: session.id }))
+    if (!history.result.ok) throw new Error('history failed')
+    expect('test/internal-count' in (history.result.value.projections?.values ?? {})).toBe(false)
+    const listing = await proxy.sessions.list(request({}))
+    if (!listing.result.ok) throw new Error('listing failed')
+    const row = listing.result.value.items.find(item => item.sessionId === session.id)
+    expect('test/internal-count' in (row?.projections?.values ?? {})).toBe(false)
+    expect(frames.some(frame => frame.type === 'session/projection' && frame.key === 'test/internal-count')).toBe(false)
+  })
+
   it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => {
     const { ctx, session } = await harness(true)
     const dispose = ctx.sessionProjections.register(lastUserUnit())

+ 1 - 1
packages/interaction/permission-presets/src/index.ts

@@ -114,7 +114,7 @@ const knobStateSchema: zod.ZodType<KnobState> = zod.object({
     zod.literal('danger-full-access'),
   ]).nullable(),
   approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(),
-})
+}).strict()
 
 /** State for the empty log: every knob at its composition default. */
 const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null }

+ 1 - 5
packages/llm/token-meter/src/breakdown-projection.ts

@@ -19,14 +19,10 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
   }
 }
 
-/**
- * The context-breakdown unit's state schema — the one definition of the state
- * shape; the state type is inferred from it (claim folds to the same shape as
- * {@link ShadowPriceClaim}).
- */
 /** Non-negative integer token count (the shared figure shape). */
 const tokenCount = z.number().int().nonnegative()
 
+/** The context-breakdown state schema and source of its inferred type. */
 const contextBreakdownStateSchema = z.object({
   systemTokens: tokenCount,
   toolsTokens: tokenCount,

+ 3 - 3
packages/llm/token-meter/src/projection.ts

@@ -33,7 +33,7 @@ export interface ContextPressureProjection {
    * plus cache reads and writes. Response output is excluded, so this does not
    * grow as the current turn streams. Absent until a provider reports usage.
    */
-  pressureTokens?: number | undefined
+  pressureTokens?: number
   /**
    * What the NEXT request's prompt would cost: {@link pressureTokens} plus the
    * heuristic repricing of everything the surface gained or lost since that
@@ -42,9 +42,9 @@ export interface ContextPressureProjection {
    * which `pressureTokens` alone cannot do, since compaction reports no usage
    * of its own. Absent until a provider reports usage.
    */
-  projectedTokens?: number | undefined
+  projectedTokens?: number
   /** Newest recorded route capacity; absent when no adapter advertised one. */
-  contextWindow?: number | undefined
+  contextWindow?: number
 }
 
 /**

+ 6 - 7
packages/llm/token-meter/src/usage-projection.ts

@@ -9,7 +9,6 @@ import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
 import type { ContextPressureProjection, TokenUsageProjection } from './projection.ts'
 import { foldSurfaceProjection } from './surface-projection.ts'
 
-
 const zeroBuckets = (): TokenUsageProjection => ({
   uncachedInputTokens: 0,
   outputTokens: 0,
@@ -67,7 +66,11 @@ const pressureSchema: z.ZodType<ContextPressureProjection> = z.object({
   pressureTokens: z.number().int().nonnegative().optional(),
   projectedTokens: z.number().int().nonnegative().optional(),
   contextWindow: z.number().int().positive().optional(),
-}).strict()
+}).strict().transform(({ pressureTokens, projectedTokens, contextWindow }) => ({
+  ...pressureTokens === undefined ? {} : { pressureTokens },
+  ...projectedTokens === undefined ? {} : { projectedTokens },
+  ...contextWindow === undefined ? {} : { contextWindow },
+}))
 
 /** Prompt-side pressure of one request: input plus cache traffic, no output. */
 const pressureFrom = (usage: TokenUsage): number =>
@@ -88,11 +91,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
   }
 }
 
-/**
- * The context-pressure unit's state schema — the one definition of the state
- * shape; the state type is inferred from it (claim folds to the same shape as
- * {@link ShadowPriceClaim}).
- */
+/** The context-pressure state schema and source of its inferred type. */
 const contextPressureStateSchema = z.object({
   contextWindow: z.number().int().positive().optional(),
   pressureTokens: z.number().int().nonnegative().optional(),

+ 7 - 4
packages/plan/plan-mode/src/index.ts

@@ -33,7 +33,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
 import type {} from '@deepseek-ai/dsh-system-prompt'
 import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
 // Type-only edge: resolves `ctx.commands` for the optional command child.
-import type {} from '@deepseek-ai/dsh-commands'
+import type { CommandId } from '@deepseek-ai/dsh-commands'
 // Type-only: resolves ctx.sessionProjections for the optional unit child.
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type { PlanProjection } from './types.ts'
@@ -148,7 +148,7 @@ interface PlanUnitState {
   /** The selection's target mode; null when no selection is outstanding. */
   wanted: boolean | null
   /** The latest plan command awaiting its paired settlement. */
-  running: { commandId: string; wanted: boolean } | null
+  running: { commandId: CommandId; wanted: boolean } | null
 }
 
 declare module '@deepseek-ai/dsh-session-projection/types' {
@@ -160,8 +160,11 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
 const planUnitStateSchema: ZodType<PlanUnitState> = zod.object({
   active: zod.boolean(),
   wanted: zod.boolean().nullable(),
-  running: zod.object({ commandId: zod.string(), wanted: zod.boolean() }).nullable(),
-})
+  running: zod.object({
+    commandId: zod.string() as unknown as ZodType<CommandId>,
+    wanted: zod.boolean(),
+  }).strict().nullable(),
+}).strict()
 
 /** Wire payload schema of the `plan` projection. */
 const planProjectionSchema: ZodType<PlanProjection> = zod.object({

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

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

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

@@ -26,9 +26,9 @@ Two mandatory points, throttled in between:
 
 Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml.
 
-## Listing read (`cachedSnapshot(meta, options?)`)
+## Listing read (`cachedSnapshot(meta)`)
 
-The zero-I/O rung: whole values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. `{ wireOnly: true }` excludes host-only rows. `undefined` when no usable record exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column.
+The zero-I/O rung: client values viewed straight from the identity-matching stored record (version- and state-schema-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. Host-only rows are never returned. `undefined` when no usable client row exists (unknown id, unrelated lifecycle, or no usable rows); the api-proxy list carrier turns that into an absent column.
 
 ## Cold read (`coldSnapshot(id, signal?)`)
 

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

@@ -26,9 +26,9 @@
 
 两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。
 
-## 列表读(`cachedSnapshot(meta, options?)`)
+## 列表读(`cachedSnapshot(meta)`)
 
-零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。`{ wireOnly: true }` 排除 host-only 行。无可用记录(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
+零 I/O 一档:从身份匹配的存储记录直接 view 客户端值(仅版本与 state schema 均匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。host-only 行永不返回。无可用客户端行(未知 id、无关生命周期、无可用行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
 
 ## 冷读(`coldSnapshot(id, signal?)`)
 

+ 5 - 10
packages/session/session-projection-cache/src/index.ts

@@ -113,17 +113,13 @@ export class SessionProjectionCache extends Service {
    * paths (the history tail baseline, {@link coldSnapshot}) supersede these
    * values whenever a session is actually opened.
    * @param meta - the listed session's header (identity witness; no log read).
-   * @param options - restrict the result to client-visible keys.
    * @returns the cut (`asOfSeq` = lowest served-row watermark), or
    *   `undefined` when no usable row exists for this lifecycle.
    */
-  cachedSnapshot(
-    meta: SessionHeader,
-    options?: { wireOnly?: boolean },
-  ): ProjectionSnapshot | undefined {
+  cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined {
     const record = this.recordFor(meta.id, identityOf(meta))
     if (record === undefined) return undefined
-    const values = this.ctx.sessionProjections.viewCheckpoint(record.rows, options)
+    const values = this.ctx.sessionProjections.viewCheckpoint(record.rows)
     const keys = Object.keys(values)
     if (keys.length === 0) return undefined
     // The block carries ONE cut: the lowest served watermark is the seq every
@@ -189,10 +185,9 @@ export class SessionProjectionCache extends Service {
       if (!related) throw new Error('unrelated log identity')
       restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
     } catch {
-      // The recoverable restore failures: an unrelated record, or a row
-      // overreaching the stored log end (or predating the floor). Both imply
-      // floor > 0 (baseSeq-0 restores never throw and an unrelated record
-      // still carried a usable watermark), so the full log is a fresh read.
+      // Recoverable failures are an unrelated record, a row outside the
+      // supplied suffix or log end, and stateSchema rejection. The full read
+      // removes every checkpoint seed and lets each unit refold from init.
       const whole = await persistence.readFrom(id, 0, signal)
       restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
     }

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

@@ -293,6 +293,19 @@ describe('SessionProjectionCache cold read', () => {
     expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('shrunk'), 0, undefined)
   })
 
+  it('discards malformed persisted state and degrades to one full re-read', async () => {
+    const pool = new MemoryMediaPool()
+    const logs = new Map([['malformed', storedLog([['real']])]])
+    seedRow(pool, 'malformed', { ver: 1, seq: 1, val: { marks: 'not-an-array' } })
+    const { cache, persistence } = await harness({ pool, logs })
+
+    const snapshot = await cache.coldSnapshot(SessionId('malformed'))
+
+    expect(snapshot.values['cache-test/marks']).toEqual({ marks: ['real'] })
+    expect(persistence.readFrom).toHaveBeenNthCalledWith(1, SessionId('malformed'), 1, undefined)
+    expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined)
+  })
+
   it('write-back failure is contained: the snapshot is still served', async () => {
     const pool = new MemoryMediaPool()
     const logs = new Map([['soft', storedLog([['a']])]])

+ 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;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: 7ef1daedad2790bac31c48315b322f19914a29af
-README.zh.md: 928a2ea88fbc0c8e8fd3398fb0c87a7a8ed3b6e7
+README.md: 55ba3e0d72229049303b246987651a2a801711a9
+README.zh.md: f312773aedc98aaf4d91261d1d3ba15ea7a9cfff

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

@@ -9,9 +9,9 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr
 ### Public API
 
 - `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence.
-- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
+- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per client-visible unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
 - `ctx.sessionProjections.stateOf(session, key)` Read one registered unit's current host state without computing unrelated views. The returned value is a live read-only reference; callers must not mutate it.
-- `ctx.sessionProjections.snapshot(session, options?): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). `{ wireOnly: true }` excludes host-only units.
+- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered client-visible unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log). Host-only state is available only through `stateOf`.
 
 ### Key Types
 

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

@@ -9,9 +9,9 @@
 ### 公开 API
 
 - `ctx.sessionProjections.register(definition): () => void` 注册一个领域的单元。key 重复或 `stateVersion` 非法都会 throw;注册是挂在调用方 fiber 上的 effect,领域插件卸载后其 key(连同缓存的 cell)从后续驱动与快照中消失——客户端将其读作能力缺失。
-- `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。
+- `ctx.sessionProjections.onChanged(listener): () => void` 订阅变更流:每个已提交事件、每个状态引用发生变化的客户端可见单元各回调一次,携带经 schema 校验的 view 与致因 seq。与 `register` 一样绑定 effect。
 - `ctx.sessionProjections.stateOf(session, key)` 读取一个已注册单元的当前 host 状态,不计算无关 view。返回值是活的只读引用;调用方不得修改。
-- `ctx.sessionProjections.snapshot(session, options?): ProjectionSnapshot` 对全部已注册单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。`{ wireOnly: true }` 排除 host 内部单元。
+- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` 对全部已注册客户端可见单元做一次一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` = 所有值共同反映到的最后一个事件的 seq(空日志为 `-1`)。host-only 状态只能通过 `stateOf` 读取。
 
 ### 关键类型
 

+ 35 - 75
packages/session/session-projection/src/index.ts

@@ -95,20 +95,16 @@ export type ProjectionChangeListener = (
   seq: number,
 ) => void
 
-/** Values in a snapshot. Wire keys use their client view; host keys use state. */
-export type ProjectionValues = { [K in keyof SessionProjectionStateMap]:
-  K extends keyof SessionProjectionMap ? SessionProjectionMap[K] : SessionProjectionStateMap[K] }
-
 /**
- * One consistent read cut over every registered unit for one session.
+ * One consistent read cut over every registered client-visible unit for one session.
  * `asOfSeq` is the shared watermark — the seq of the last event every value
  * reflects (`-1` for an empty log, mirroring `session/subscribed.lastSeq`).
  */
 export interface ProjectionSnapshot {
   /** Seq of the last event the values reflect; -1 for an empty log. */
   asOfSeq: number
-  /** Whole current value per registered key. */
-  values: Partial<ProjectionValues>
+  /** Whole current client value per registered key. */
+  values: Partial<SessionProjectionMap>
 }
 
 /**
@@ -171,7 +167,8 @@ interface Registration {
  * `ctx.sessionProjections`: the projection unit table and its drive. The
  * service subscribes to `session/event` once; every committed event passes
  * every registered unit's `apply` (eager drive), and a changed state
- * reference notifies the change feed with the schema-validated view.
+ * reference in a client-visible unit notifies the change feed with the
+ * schema-validated view.
  * 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 (disposer rides the
@@ -210,7 +207,10 @@ export class SessionProjectionRegistry extends Service {
     K extends keyof SessionProjectionMap,
     S extends SessionProjectionStateMap[K],
   >(
-    definition: ProjectionDefinition<K, S> & { wire: NonNullable<ProjectionDefinition<K, S>['wire']> },
+    definition: Omit<ProjectionDefinition<K, S>, 'wire' | 'persist'> & {
+      wire: NonNullable<ProjectionDefinition<K, S>['wire']>
+      persist?: true
+    },
   ): () => void
   /**
    * Register one host-only unit. Its state is omitted from client snapshots
@@ -251,13 +251,12 @@ export class SessionProjectionRegistry extends Service {
       if (existing === undefined) {
         this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 })
       } else {
-        // A differing `stateVersion` is the one incompatibility this can name:
-        // the versioned contract says the cached state shape differs, so the
-        // two registrants cannot share cells. Anything else about a definition
-        // is functions, which no runtime comparison can tell apart.
         if (existing.def.stateVersion !== erased.stateVersion) {
           throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`)
         }
+        if (existing.def.persist !== erased.persist) {
+          throw new Error(`session projection key ${JSON.stringify(key)} is already registered with persist ${String(existing.def.persist)}; refusing to share it with persist ${String(erased.persist)}`)
+        }
         existing.refs += 1
       }
       yield () => {
@@ -274,7 +273,7 @@ export class SessionProjectionRegistry extends Service {
   /**
    * Subscribe to the change feed. The registration is an effect on the
    * calling context's fiber.
-   * @param listener - called once per unit whose state reference changed, per committed event.
+   * @param listener - called once per client-visible unit whose state reference changed, per committed event.
    * @returns the exact disposer that unsubscribes.
    */
   onChanged(listener: ProjectionChangeListener): () => void {
@@ -304,22 +303,19 @@ export class SessionProjectionRegistry extends Service {
   }
 
   /**
-   * One consistent cut over every registered unit for one session, read from
+   * One consistent cut over every registered client-visible unit for one session, read from
    * the watermark cache (missing cells fold lazily over the in-memory log).
    * Fully synchronous — every value and `asOfSeq` reflect the same log
-   * position. Each value passes its unit's schema before leaving.
+   * position. Each value passes its unit's `viewSchema` before leaving.
    * @param session - the session whose projection values are read.
-   * @param options - restrict the result to client-visible keys.
-   * @returns the snapshot; `values` is empty when no unit is registered.
+   * @returns the snapshot; `values` is empty when no client-visible unit is registered.
    */
-  snapshot(session: Session, options?: { wireOnly?: boolean }): ProjectionSnapshot {
+  snapshot(session: Session): ProjectionSnapshot {
     const values: Record<string, unknown> = {}
     for (const registration of this.registrations.values()) {
-      if (options?.wireOnly === true && registration.def.wire === undefined) continue
+      if (registration.def.wire === undefined) continue
       const cell = this.cellFor(registration, session)
-      values[registration.def.key] = registration.def.wire === undefined
-        ? cell.state
-        : registration.def.wire.viewSchema.parse(registration.def.wire.view(cell.state))
+      values[registration.def.key] = registration.def.wire.viewSchema.parse(registration.def.wire.view(cell.state))
     }
     return { asOfSeq: session.seq - 1, values }
   }
@@ -336,7 +332,7 @@ export class SessionProjectionRegistry extends Service {
    * every subsequent snapshot and frame through it (plain JSON by the unit
    * contract, so the clone is total).
    * @param session - the session whose unit states are checkpointed.
-   * @returns one row per registered key; empty when no unit is registered.
+   * @returns one row per persisted key; empty when no persisted unit is registered.
    */
   checkpoint(session: Session): ProjectionCheckpoint {
     const rows: ProjectionCheckpoint = {}
@@ -365,7 +361,7 @@ export class SessionProjectionRegistry extends Service {
    * re-read.
    * @param checkpoint - persisted rows for one session (possibly stale or empty).
    * @returns the seq to hand the persistence `readFrom`, or `undefined`
-   *   when no unit is registered (no read needed — {@link restore} would
+   *   when no persisted unit is registered (no read needed — {@link restore} would
    *   serve empty values regardless).
    */
   restoreFloor(checkpoint: ProjectionCheckpoint): number | undefined {
@@ -383,23 +379,19 @@ export class SessionProjectionRegistry extends Service {
 
   /**
    * View a checkpoint's rows without any log read: for every registered
-   * unit whose row's `ver` matches, serve the schema-validated
+   * client-visible unit whose row's `ver` matches, serve the schema-validated
    * `view` of the schema-validated stored state; mismatched, malformed, 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 —
    * values are as stale as their rows, never wrong.
    * @param checkpoint - persisted rows for one session (possibly stale or empty).
-   * @param options - restrict the result to client-visible keys.
    * @returns whole values per key with a usable row; empty when none.
    */
-  viewCheckpoint(
-    checkpoint: ProjectionCheckpoint,
-    options?: { wireOnly?: boolean },
-  ): Partial<ProjectionValues> {
+  viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap> {
     const values: Record<string, unknown> = {}
     for (const registration of this.registrations.values()) {
       const def = registration.def
-      if (!def.persist || (options?.wireOnly === true && def.wire === undefined)) continue
+      if (def.wire === undefined) continue
       const row = checkpoint[def.key]
       if (row === undefined || row.ver !== def.stateVersion) continue
       let state: unknown
@@ -408,9 +400,7 @@ export class SessionProjectionRegistry extends Service {
       } catch {
         continue
       }
-      values[def.key] = def.wire === undefined
-        ? state
-        : def.wire.viewSchema.parse(def.wire.view(state))
+      values[def.key] = def.wire.viewSchema.parse(def.wire.view(state))
     }
     return values
   }
@@ -433,7 +423,6 @@ export class SessionProjectionRegistry extends Service {
    * @param checkpoint - persisted rows for one session (possibly stale or empty).
    * @param events - the stored events with `seq >= baseSeq`, in seq order.
    * @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
-   * @param options - restrict returned values to client-visible keys.
    * @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
    *   supplied event's seq, `baseSeq - 1` for an empty tail) plus the
    *   refreshed checkpoint rows at that cut, ready for a durable write-back.
@@ -442,7 +431,6 @@ export class SessionProjectionRegistry extends Service {
     checkpoint: ProjectionCheckpoint,
     events: readonly SessionEvent[],
     baseSeq: number,
-    options?: { wireOnly?: boolean },
   ):
   { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
     const endSeq = events.at(-1)?.seq ?? baseSeq - 1
@@ -467,11 +455,7 @@ export class SessionProjectionRegistry extends Service {
       for (const event of events) {
         if (event.seq > from) state = def.apply(state, event)
       }
-      if (options?.wireOnly !== true || def.wire !== undefined) {
-        values[def.key] = def.wire === undefined
-          ? state
-          : def.wire.viewSchema.parse(def.wire.view(state))
-      }
+      if (def.wire !== undefined) values[def.key] = def.wire.viewSchema.parse(def.wire.view(state))
       refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
     }
     return {
@@ -493,39 +477,10 @@ export class SessionProjectionRegistry extends Service {
     if (cell === undefined) {
       cell = this.buildCell(registration.def, session.events)
       registration.cells.set(session, cell)
-      return cell
-    }
-    if (cell.observedSeq < session.seq - 1) {
-      for (const event of session.events.slice(cell.observedSeq + 1)) {
-        this.applyToCell(registration, session, cell, event)
-      }
     }
     return cell
   }
 
-  /** Apply one event to a cell and isolate change-feed subscriber failures. */
-  private applyToCell(
-    registration: Registration,
-    session: Session,
-    cell: UnitCell,
-    event: SessionEvent,
-  ): void {
-    const def = registration.def
-    const next = def.apply(cell.state, event)
-    const changed = !Object.is(next, cell.state)
-    cell.state = next
-    cell.observedSeq = event.seq
-    if (!changed || def.wire === undefined || this.listeners.size === 0) return
-    const value = def.wire.viewSchema.parse(def.wire.view(next))
-    for (const listener of this.listeners) {
-      try {
-        listener(session, def.key as Extract<keyof SessionProjectionMap, string>, value, event.seq)
-      } catch (error) {
-        this.ctx.logger.warn(`session projection change listener threw: ${String(error)}`)
-      }
-    }
-  }
-
   /** Eager drive: pass one committed event through every registered unit; notify on changed references. */
   private drive(session: Session, event: SessionEvent): void {
     for (const registration of this.registrations.values()) {
@@ -536,11 +491,16 @@ export class SessionProjectionRegistry extends Service {
         cell = this.buildCell(registration.def, session.events.slice(0, event.seq))
         registration.cells.set(session, cell)
       }
-      if (cell.observedSeq >= event.seq) continue
-      for (const prior of session.events.slice(cell.observedSeq + 1, event.seq)) {
-        this.applyToCell(registration, session, cell, prior)
+      const next = registration.def.apply(cell.state, event)
+      const changed = !Object.is(next, cell.state)
+      cell.state = next
+      cell.observedSeq = event.seq
+      if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
+        const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next))
+        for (const listener of this.listeners) {
+          listener(session, registration.def.key as Extract<keyof SessionProjectionMap, string>, value, event.seq)
+        }
       }
-      this.applyToCell(registration, session, cell, event)
     }
   }
 }

+ 4 - 4
packages/session/session-projection/src/types.ts

@@ -9,10 +9,10 @@
  */
 
 /**
- * The single projection type table for the whole chain (host provider, wire
- * block, client cell, React hook). Domain packages merge their key here via
- * declaration merging; values are wire-JSON whole values. How a value is
- * rendered is the slot system's business, never this layer's.
+ * The merge-extensible client projection table shared by wire blocks, client
+ * cells, and React hooks. Domain packages merge their client-visible key here;
+ * values are wire-JSON whole values. How a value is rendered is the slot
+ * system's business, never this layer's.
  */
 export interface SessionProjectionMap {}
 

+ 30 - 105
packages/session/session-projection/tests/registry.spec.ts

@@ -34,7 +34,7 @@ declare module '@deepseek-ai/dsh-session/types' {
 
 type MarksState = { marks: string[] } | null
 /** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */
-const marksUnit = (): ProjectionDefinition<'test/marks', MarksState>
+const marksUnit = (): Omit<ProjectionDefinition<'test/marks', MarksState>, 'wire' | 'persist'>
   & { wire: NonNullable<ProjectionDefinition<'test/marks', MarksState>['wire']> } => ({
   key: 'test/marks',
   stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
@@ -109,90 +109,6 @@ describe('SessionProjectionRegistry drive', () => {
     expect(seen).toEqual([{ key: 'test/marks', value: { marks: ['a'] }, seq: event.seq, sessionId: String(session.id) }])
   })
 
-  it('contains a throwing change listener and continues the remaining feed', async () => {
-    const { ctx, session } = await harness()
-    ctx.sessionProjections.register(marksUnit())
-    const seen: string[] = []
-    ctx.sessionProjections.onChanged(() => {
-      throw new Error('change listener boom')
-    })
-    ctx.sessionProjections.onChanged((_session, key) => {
-      seen.push(key)
-    })
-
-    expect(() => mark(session, ['contained'])).not.toThrow()
-    expect(seen).toEqual(['test/marks'])
-  })
-
-  it('publishes only client-visible changes and honors the direct disposer', async () => {
-    const { ctx, session } = await harness()
-    ctx.sessionProjections.register(marksUnit())
-    ctx.sessionProjections.register(countUnit())
-    const seen: string[] = []
-    const dispose = ctx.sessionProjections.onChanged((_session, key) => {
-      seen.push(key)
-    })
-    mark(session, ['wire'])
-    expect(seen).toEqual(['test/marks'])
-    dispose()
-    mark(session, ['disposed'])
-    expect(seen).toEqual(['test/marks'])
-  })
-
-  it('makes an earlier session listener read current and skips duplicate drive application', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    const seen: unknown[] = []
-    ctx.on('session/event', (session) => {
-      seen.push(ctx.sessionProjections.snapshot(session).values['test/marks'])
-    })
-    await ctx.plugin(SessionProjectionRegistry)
-    ctx.sessionProjections.register(marksUnit())
-    const session = ctx.sessions.create()
-
-    mark(session, ['early listener'])
-
-    expect(seen).toEqual([{ marks: ['early listener'] }])
-    expect(ctx.sessionProjections.snapshot(session).values['test/marks'])
-      .toEqual({ marks: ['early listener'] })
-  })
-
-  it('forward-applies events appended while the session is detached', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    ctx.sessionProjections.register(countUnit())
-    const session = ctx.sessions.prepare()
-    const detach = ctx.sessions.enter(session)
-    session.append('turn/start', { turn: 1 })
-    expect(ctx.sessionProjections.snapshot(session).values['test/count']).toBe(1)
-    detach()
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-    session.append('turn/start', { turn: 2 })
-    ctx.sessions.enter(session)
-
-    session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
-
-    expect(ctx.sessionProjections.snapshot(session).values['test/count']).toBe(4)
-  })
-
-  it('brings a detached session cell current on its first read after reattachment', async () => {
-    const ctx = new Context()
-    await ctx.plugin(SessionStore)
-    await ctx.plugin(SessionProjectionRegistry)
-    ctx.sessionProjections.register(countUnit())
-    const session = ctx.sessions.prepare()
-    const detach = ctx.sessions.enter(session)
-    session.append('turn/start', { turn: 1 })
-    expect(ctx.sessionProjections.snapshot(session).values['test/count']).toBe(1)
-    detach()
-    session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
-    session.append('turn/start', { turn: 2 })
-    ctx.sessions.enter(session)
-
-    expect(ctx.sessionProjections.snapshot(session).values['test/count']).toBe(3)
-  })
-
   it('drives independently per session (cells are per-session watermarks)', async () => {
     const { ctx, session } = await harness()
     const other = ctx.sessions.create()
@@ -213,9 +129,8 @@ describe('SessionProjectionRegistry drive', () => {
     })
     session.append('turn/start', { turn: 1 })
     expect(changedKeys).toEqual([])
-    const snapshot = ctx.sessionProjections.snapshot(session)
-    expect(snapshot.values['test/count']).toBe(1)
-    expect(snapshot.values['test/marks']).toEqual({ marks: [] })
+    expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1)
+    expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/marks': { marks: [] } })
   })
 
   it('shares one unit between registrants of the same key', async () => {
@@ -257,6 +172,14 @@ describe('SessionProjectionRegistry drive', () => {
       .toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/)
   })
 
+  it('refuses to share a key across a persistence-policy change', async () => {
+    const { ctx } = await harness()
+    ctx.sessionProjections.register(countUnit())
+
+    expect(() => ctx.sessionProjections.register({ ...countUnit(), persist: false }))
+      .toThrow(/already registered with persist true; refusing to share it with persist false/)
+  })
+
   it('rejects a non-integer or negative stateVersion at register time', async () => {
     const { ctx } = await harness()
     expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/)
@@ -291,14 +214,15 @@ describe('SessionProjectionRegistry drive', () => {
     expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
   })
 
-  it('snapshot serves the wire view for a client key and the raw state for a host-internal key', async () => {
+  it('snapshot serves client views and excludes host-only state', async () => {
     const { ctx, session } = await harness()
     ctx.sessionProjections.register(marksUnit())
     ctx.sessionProjections.register(countUnit())
     mark(session, ['a', 'b'])
     const values = ctx.sessionProjections.snapshot(session).values
     expect(values['test/marks']).toEqual({ marks: ['a', 'b'] })
-    expect(values['test/count']).toEqual(1)
+    expect('test/count' in values).toBe(false)
+    expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1)
     expect('test/unregistered' in values).toBe(false)
   })
 
@@ -379,7 +303,7 @@ describe('SessionProjectionRegistry drive', () => {
     }, 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
+    expect('test/count' in snapshot.values).toBe(false)
     // The refreshed rows sit at the served cut, ready for a durable write-back.
     expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } })
     expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
@@ -397,20 +321,22 @@ describe('SessionProjectionRegistry drive', () => {
       { type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } },
       { type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
     ]
-    const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
+    const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3)
     expect(snapshot.asOfSeq).toBe(4)
     // marks already covers the tail (watermark 4): nothing re-applied.
     expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] })
-    // count folds exactly seqs 3 and 4 on top of its checkpoint.
-    expect(snapshot.values['test/count']).toBe(5)
+    // count folds exactly seqs 3 and 4 on top of its checkpoint, but remains host-only.
+    expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
+    expect('test/count' in snapshot.values).toBe(false)
 
     // Empty tail (checkpoint is current): the cut sits at baseSeq - 1.
-    const { snapshot: current } = ctx.sessionProjections.restore({
+    const { snapshot: current, checkpoint: currentCheckpoint } = ctx.sessionProjections.restore({
       '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)
+    expect('test/count' in current.values).toBe(false)
+    expect(currentCheckpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
   })
 
   it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => {
@@ -426,7 +352,7 @@ describe('SessionProjectionRegistry drive', () => {
     expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({})
   })
 
-  it('viewCheckpoint and restore can serve wire keys while retaining full host checkpoints', async () => {
+  it('viewCheckpoint and restore exclude host-only state while retaining its checkpoint', async () => {
     const { ctx } = await harness()
     ctx.sessionProjections.register(marksUnit())
     ctx.sessionProjections.register(countUnit())
@@ -436,13 +362,9 @@ describe('SessionProjectionRegistry drive', () => {
     }
     expect(ctx.sessionProjections.viewCheckpoint(rows)).toEqual({
       'test/marks': { marks: ['stored'] },
-      'test/count': 5,
-    })
-    expect(ctx.sessionProjections.viewCheckpoint(rows, { wireOnly: true })).toEqual({
-      'test/marks': { marks: ['stored'] },
     })
 
-    const restored = ctx.sessionProjections.restore(rows, [], 5, { wireOnly: true })
+    const restored = ctx.sessionProjections.restore(rows, [], 5)
     expect(restored.snapshot.values).toEqual({
       'test/marks': { marks: ['stored'] },
     })
@@ -470,7 +392,9 @@ describe('SessionProjectionRegistry drive', () => {
     expect(floor).toBe(9)
     // …an intact log serves the anchor event and the checkpoint stands as-is.
     const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
-    expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10)
+    const anchored = ctx.sessionProjections.restore(rows, [anchor], 9)
+    expect(anchored.snapshot.values).toEqual({})
+    expect(anchored.checkpoint['test/count']).toEqual({ ver: 1, seq: 9, val: 10 })
     // …while a log crash-repaired down to fewer events returns an empty tail:
     // the row overreaches the proven end and a tail read cannot fix this key.
     expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/)
@@ -479,9 +403,10 @@ describe('SessionProjectionRegistry drive', () => {
       { type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
       { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
     ]
-    const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
+    const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0)
     expect(snapshot.asOfSeq).toBe(1)
-    expect(snapshot.values['test/count']).toBe(2)
+    expect(snapshot.values).toEqual({})
+    expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 1, val: 2 })
   })
 
   it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => {

+ 7 - 8
packages/subagent/subagent/src/projection.ts

@@ -24,22 +24,21 @@ export interface TimingState {
   descriptorSeen: boolean
 }
 
+const activeIntervalSchema = z.object({
+  since: z.number().int().nonnegative(),
+  through: z.number().int().nonnegative(),
+}).strict()
+
 // Zod's optional output includes explicit `undefined`; with
 // exactOptionalPropertyTypes the public interface permits omission only.
 const projectionSchema = z.object({
   settledMs: z.number().int().nonnegative(),
-  active: z.object({
-    since: z.number().int().nonnegative(),
-    through: z.number().int().nonnegative(),
-  }).strict().optional(),
+  active: activeIntervalSchema.optional(),
 }).strict() as unknown as z.ZodType<SubagentTimingProjection>
 
 const timingStateSchema: z.ZodType<TimingState> = z.object({
   settledMs: z.number().int().nonnegative(),
-  active: z.object({
-    since: z.number().int().nonnegative(),
-    through: z.number().int().nonnegative(),
-  }).strict().optional(),
+  active: activeIntervalSchema.optional(),
   pendingTurnStart: z.number().int().nonnegative().optional(),
   descriptorSeen: z.boolean(),
 }).strict()

+ 0 - 1
scripts/gen-cordis-catalog.ts

@@ -493,7 +493,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SessionProjectionMap: 'session-projection.md',
   SessionProjectionStateMap: 'session-projection.md',
   ProjectionChangeListener: 'session-projection.md',
-  ProjectionValues: 'session-projection.md',
   ProjectionSnapshot: 'session-projection.md',
   ProjectionCheckpoint: 'session-projection.md',
   DirectoryPickerCapability: 'workspace.md',