Explorar el Código

revert(session-projection): drop the viewKey token, keep the per-step raw-view gate

Review consensus: for the only declaring unit the token function was
literally the view function, so the extra wire member bought nothing —
default raw-view comparison produces the identical Object.is on
state.turns at the identical cost (one property read per side). The
per-step gate stays: it holds the no-stored-baseline property, and its
measured overhead (~ns per changed state) is four orders of magnitude
below the push path it guards.
Yichen Jiang hace 2 semanas
padre
commit
a2437db180

+ 2 - 2
.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.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/feature/2026-08-30-web-turn-rail-outline-jump.md
-2026-08-30-web-turn-rail-outline-jump.md: 0c14be30a61f277b05ad3da2169cae5924f89f0b
-2026-08-30-web-turn-rail-outline-jump.zh.md: 707d7e5e8c76bf1fb891628fc0730c72f5ce7612
+2026-08-30-web-turn-rail-outline-jump.md: ca59263d10dfd84984cae454a2e45027ff1d018e
+2026-08-30-web-turn-rail-outline-jump.zh.md: ab5df81b8521332ef9b3e7f2fac9e3f2fe795d9d

+ 1 - 1
.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.md

@@ -14,7 +14,7 @@ Three cooperating pieces, each useful alone.
 
 **Data: the `turnOutline` session projection.** `packages/session/session-turn-outline` registers one pure fold on `ctx.sessionProjections`: every `turn/start` appends an entry (skipping boundaries that do not advance the turn number, keeping the outline strictly increasing), the turn's first human `user/message` fills the prompt preview, and the newest text-bearing `assistant/message` buffers a response draft that `turn/end` commits (`turn/end` itself carries no text). Preview budgets mirror the rail card's clamps — one prompt line at 50 characters, up to three response lines at 120, an ellipsis marking a clip — and match the loaded-turn previews so a turn shows the same words before and after its events load. The wire value is the bare entry array so draft-only state changes keep its identity, and the feed's identity gate (below) then holds pushes to three per turn: boundary, prompt, settled response. The value rides the existing projection carriers — tail-page seed, `session/projection` control frames, projcache — and the web-app bundle mounts the plugin. `seq` is the `turn/start` event seq: the loop logs it before the turn's prompt and steps, so paging a window back through that seq loads the whole turn.
 
-**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now compares the unit's `viewKey` token (default: the raw `view` output) across the previous and next states and stays quiet when `Object.is`-identical; both tokens are computed in the driving step (review moved this off a stored last-delivered value, then onto a declared cheap token), so no dedup memory exists to go stale and `view` runs only for an actual push. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
+**Change-feed identity gate (session-projection).** The registry's change feed previously fired on every changed state reference of a client-visible unit; it now also compares the raw `view` output of the previous and next states and stays quiet when `Object.is`-identical; both views are computed in the driving step (review moved this off a stored last-delivered value), so no dedup memory exists to go stale across listener generations. This is what lets a unit buffer working fields (the response draft) in state behind an identity-stable projection instead of pushing its whole value per streamed assistant message; units whose views build fresh objects per call are unaffected. The alternative — value-equality dedup in the carrier by serialized comparison — was rejected as it pays a full serialization per quiet change.
 
 **Paging: `Session.loadThrough(seq)`.** The session-controller client gains a jump loader beside `loadOlder()`: it loops the existing prepend pager in 200-message pages (`JUMP_PAGE_MESSAGES`) until `baseSeq <= seq`, lowers a shared low-water target when called again mid-jump, stops on a page that leaves `baseSeq` unmoved (the no-progress guard against an empty page still claiming history), and reports busy through the existing `loadingOlder` snapshot bit. No wire change: seqs are dense, so the client computes everything from `beforeSeq` arithmetic.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-30-web-turn-rail-outline-jump.zh.md

@@ -14,7 +14,7 @@ Web 聊天的轮次导航栏从已加载的事件窗口推导刻度,而窗口
 
 **数据:`turnOutline` 会话投影。** `packages/session/session-turn-outline` 在 `ctx.sessionProjections` 上注册一个纯 fold:每个 `turn/start` 追加一个条目(跳过未推进轮次号的边界,保持大纲严格递增),该轮首条人类 `user/message` 填入提示词预览,最新一条带文本的 `assistant/message` 缓冲为回复草稿、由 `turn/end` 提交(`turn/end` 自身不带文本)。预览预算对齐导航卡片的截断——提示词一行 50 字符、回复至多三行 120 字符、被裁剪时补省略号——并与已加载轮次的预览一致,同一轮在事件载入前后显示相同的文字。wire 值是裸条目数组,纯草稿的状态变化因此保持其身份,配合下述变更流身份门把推送压到每轮三次:开轮、提示词、落定回复。值搭现有投影载体——尾页 seed、`session/projection` 控制帧、projcache——web-app bundle 挂载该插件。`seq` 是 `turn/start` 事件的 seq:loop 先记它再记该轮的提示词与步骤,窗口向后分页越过该 seq 即载入整轮。
 
-**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在会把单元声明的 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相互比较,`Object.is` 相同即保持安静;两侧令牌都在驱动当步现算(评审先把「存上一次交付值」改为现算、再落到声明式廉价令牌),不存去重记忆,`view` 只在真推送时运行。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
+**变更流身份门(session-projection)。** 注册表的变更流此前对客户端可见单元的每次状态引用变化都触发;现在还会把前后两个状态的原始 `view` 输出相互比较,`Object.is` 相同即保持安静;两侧视图都在驱动当步现算(评审后从「存上一次交付值」改为现算),不存去重记忆,监听器换代也无从拿到过期基线。正是它让单元可以把工作字段(回复草稿)缓冲在身份稳定投影之后的状态里,而不是每条流式助手消息都推送整值;view 每次新建对象的单元不受影响。备选——在载体侧按序列化比较去重——被否决,因为每次安静变化都要付一次完整序列化。
 
 **分页:`Session.loadThrough(seq)`。** session-controller 客户端在 `loadOlder()` 旁新增跳转加载器:按 200 条 message 一页(`JUMP_PAGE_MESSAGES`)循环现有 prepend 分页器直到 `baseSeq <= seq`,跳转中再次调用会下调共享低水位目标,遇到 `baseSeq` 未动的页即停(对空页仍声称有历史的无进展守卫),忙碌状态复用现有 `loadingOlder` 快照位。零 wire 改动:seq 稠密,客户端仅凭 `beforeSeq` 算术即可。
 

+ 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: 5943281952def6e86f819e73c7822dc7f6ed4ed6
-session-projection.zh.md: 99009a0472ed40f418527848e91523d0ff0e2303
+session-projection.md: 52a745b31c5c6a21d58e0f27f93df812e49e0e98
+session-projection.zh.md: 044351647a9e16df921d8a41478f58ac4f9964d0

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

@@ -52,18 +52,6 @@ interface ProjectionDefinition<
      * @returns the whole current value for this unit's key.
      */
     view(state: NoInfer<S>): SessionProjectionMap[K]
-    /**
-     * Change-detection token for the served view: after a changed `apply`,
-     * the feed compares this token across the previous and next states with
-     * `Object.is` and stays quiet when identical, so `view` runs only for an
-     * actual push. Must be a cheap pure read (a state field, not a
-     * computation). Omitted, the raw `view` output itself is the token —
-     * correct for identity-stable views, but then `view` runs per changed
-     * state, and views building fresh objects per call push on every change.
-     * @param state - a state on either side of the comparison.
-     * @returns the token deciding whether the served view changed.
-     */
-    viewKey?(state: NoInfer<S>): unknown
   } : never
   /**
    * Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -98,9 +86,9 @@ interface ProjectionSnapshot {
  * Change-feed listener: one unit's served value changed for one session.
  * `value` is the schema-validated `view` output; `seq` is the unit's
  * watermark at emission (the seq of the event that caused the change). A
- * changed state whose `viewKey` token (default: the raw `view` output) is
- * `Object.is`-identical to the previous state's does not fire, so a unit can
- * buffer working fields in state behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
  */
 type ProjectionChangeListener = (
   session: Session,
@@ -110,7 +98,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. 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 — unless the unit's `viewKey` token (default: the raw `view` output) is `Object.is`-identical across the previous and next states (both tokens are computed in the driving step — no stored comparison value exists to go stale, and a declared `viewKey` keeps `view` off the quiet path), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `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 — unless the raw `view` output is `Object.is`-identical to the previous state's (both views are computed in the driving step; no stored comparison value exists to go stale), so a unit can buffer working fields in state behind an identity-stable projection and a later listener generation still sees every value transition; `apply` must return the same reference when its state did not change.
 
 ## The registry: `ctx.sessionProjections`
 
@@ -192,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
 
 ### `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 in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. 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. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. 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 — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). 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. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. 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
 /**

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

@@ -52,18 +52,6 @@ interface ProjectionDefinition<
      * @returns the whole current value for this unit's key.
      */
     view(state: NoInfer<S>): SessionProjectionMap[K]
-    /**
-     * Change-detection token for the served view: after a changed `apply`,
-     * the feed compares this token across the previous and next states with
-     * `Object.is` and stays quiet when identical, so `view` runs only for an
-     * actual push. Must be a cheap pure read (a state field, not a
-     * computation). Omitted, the raw `view` output itself is the token —
-     * correct for identity-stable views, but then `view` runs per changed
-     * state, and views building fresh objects per call push on every change.
-     * @param state - a state on either side of the comparison.
-     * @returns the token deciding whether the served view changed.
-     */
-    viewKey?(state: NoInfer<S>): unknown
   } : never
   /**
    * Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -98,9 +86,9 @@ interface ProjectionSnapshot {
  * Change-feed listener: one unit's served value changed for one session.
  * `value` is the schema-validated `view` output; `seq` is the unit's
  * watermark at emission (the seq of the event that caused the change). A
- * changed state whose `viewKey` token (default: the raw `view` output) is
- * `Object.is`-identical to the previous state's does not fire, so a unit can
- * buffer working fields in state behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
  */
 type ProjectionChangeListener = (
   session: Session,
@@ -110,7 +98,7 @@ type ProjectionChangeListener = (
 ) => void
 ```
 
-`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上 `Object.is` 相同(两侧令牌都在驱动当步现算,不存比较值故无从过期;声明了 `viewKey` 时安静路径完全不跑 `view`),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
+`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。对于每个已提交事件,变更流会为每个状态*引用*已变化的客户端可见单元触发一次——除非原始 `view` 输出与上一个状态的投影 `Object.is` 相同(两侧视图都在驱动当步现算,不存比较值故无从过期),因此单元可以把工作字段缓冲在状态里,用身份稳定的投影保持安静,后来的监听者也不会错过任何值变化;状态未变时,`apply` 必须返回同一引用。
 
 ## 注册表:`ctx.sessionProjections`
 
@@ -192,7 +180,7 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package
 
 ### `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 in a client-visible unit notifies the change feed with the schema-validated view — unless the unit's `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. 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. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. 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 — unless the raw view output is `Object.is`-identical to the unit's previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). 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. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. 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
 /**

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

@@ -1574,7 +1574,7 @@ 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 in a client-visible unit notifies the change feed with the schema-validated view — unless the unit\'s `viewKey` token (default: the raw view output) is `Object.is`-identical across the previous and next states. Both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and `view` runs only for an actual push when a `viewKey` is declared. 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. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. 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 — unless the raw view output is `Object.is`-identical to the unit\'s previous projection (identity-stable projections stay quiet; both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations). 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. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. 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: Omit<ProjectionDefinition<K, S>, \'wire\'> & { wire: NonNullable<ProjectionDefinition<K, S>[\'wire\']> }, ): () => void',
@@ -4593,7 +4593,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ProjectionDefinition',
-    declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {\n    key: K;\n    stateSchema: ZodType<S>;\n    init(header: SessionHeader): 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        viewKey?(state: NoInfer<S>): unknown;\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(header: SessionHeader): 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',

+ 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: 2e4a5f7897c62e18899f92e3aa04d9ccff51b161
-README.zh.md: 7b02d534cb8dc7c6150084b7aa140200210d7b71
+README.md: 753b83c2f8e7fa7ec9bf40b6107f9067c27f8362
+README.zh.md: dd036b4553f406e1cbe1b9f9280c8c682da8d1be

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

@@ -78,7 +78,7 @@ This section explains the drive machinery and the unit contract; the observable
 
 ### Design concept
 
-The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose `viewKey` token (default: the raw `view` output) is identical across the previous and next states stays quiet — both tokens are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations, and a declared `viewKey` keeps `view` itself off the quiet path (a unit can buffer working fields behind an identity-stable projection). 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 and fails `wire.viewSchema.parse`.
+The package is the Service Definition and drive role of a capability seam: the framework drives, the domain computes. The registry subscribes to `session/event` once; every committed event passes every registered unit's `apply` eagerly (cells build lazily on first touch). The change feed is gated on `Object.is` twice — a unit that returns the same state reference costs one call and nothing downstream, and a changed state whose raw `view` output is identical to the previous state's stays quiet — both views are computed from the states in hand each step, so no stored comparison value exists to go stale across listener generations (a unit can buffer working fields behind an identity-stable projection). 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 and fails `wire.viewSchema.parse`.
 
 ### Source map
 

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

@@ -78,7 +78,7 @@ const { asOfSeq, values } = ctx.sessionProjections.snapshot(session)
 
 ### 设计理念
 
-本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但 `viewKey` 令牌(缺省为原始 `view` 输出)在前后两个状态上相同的同样保持安静——两侧令牌都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期;声明了 `viewKey` 的单元在安静路径上完全不跑 `view`(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
+本包是能力 seam 的 Service Definition 与驱动角色:框架负责驱动,领域负责计算。注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个已注册单元的 `apply`(cell 在首次触达时惰性构建)。变更流以 `Object.is` 把关两道——返回同一状态引用的单元只花一次调用、不产生任何下游工作;状态已变但原始 `view` 输出与上一个状态的投影相同的同样保持安静——两侧视图都在驱动当步现算、不存任何比较值,因此没有东西会在监听器换代期间过期(单元因此可以把工作字段缓冲在身份稳定的投影之后)。载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此;误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
 
 ### 源码地图
 

+ 17 - 41
packages/session/session-projection/src/index.ts

@@ -72,18 +72,6 @@ export interface ProjectionDefinition<
      * @returns the whole current value for this unit's key.
      */
     view(state: NoInfer<S>): SessionProjectionMap[K]
-    /**
-     * Change-detection token for the served view: after a changed `apply`,
-     * the feed compares this token across the previous and next states with
-     * `Object.is` and stays quiet when identical, so `view` runs only for an
-     * actual push. Must be a cheap pure read (a state field, not a
-     * computation). Omitted, the raw `view` output itself is the token —
-     * correct for identity-stable views, but then `view` runs per changed
-     * state, and views building fresh objects per call push on every change.
-     * @param state - a state on either side of the comparison.
-     * @returns the token deciding whether the served view changed.
-     */
-    viewKey?(state: NoInfer<S>): unknown
   } : never
   /**
    * Persisted-cache invalidation version: bump whenever the serialized state fields or the
@@ -98,9 +86,9 @@ export interface ProjectionDefinition<
  * Change-feed listener: one unit's served value changed for one session.
  * `value` is the schema-validated `view` output; `seq` is the unit's
  * watermark at emission (the seq of the event that caused the change). A
- * changed state whose `viewKey` token (default: the raw `view` output) is
- * `Object.is`-identical to the previous state's does not fire, so a unit can
- * buffer working fields in state behind an identity-stable projection.
+ * changed state whose raw `view` output is `Object.is`-identical to the
+ * unit's previous projection does not fire, so a unit can buffer working
+ * fields in state behind an identity-stable projection.
  */
 export type ProjectionChangeListener = (
   session: Session,
@@ -147,11 +135,7 @@ interface ErasedDefinition {
   stateSchema: { parse(value: unknown): unknown }
   init(header: SessionHeader): unknown
   apply(state: unknown, event: SessionEvent): unknown
-  wire: {
-    viewSchema: { parse(value: unknown): unknown }
-    view(state: unknown): unknown
-    viewKey?(state: unknown): unknown
-  } | undefined
+  wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined
   stateVersion: number
 }
 
@@ -185,11 +169,10 @@ interface Registration {
  * 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 — unless the unit's `viewKey` token (default: the raw
- * view output) is `Object.is`-identical across the previous and next states.
- * Both tokens are computed from the states in hand each step, so no stored
- * comparison value exists to go stale across listener generations, and
- * `view` runs only for an actual push when a `viewKey` is declared.
+ * schema-validated view — unless the raw view output is `Object.is`-identical
+ * to the unit's previous projection (identity-stable projections stay quiet;
+ * both views are computed from the states in hand each step, so no stored
+ * comparison value exists to go stale across listener generations).
  * 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
@@ -261,9 +244,7 @@ export class SessionProjectionRegistry extends Service {
     const wire = definition.wire as {
       viewSchema: ZodType
       view(state: S): unknown
-      viewKey?(state: S): unknown
     } | undefined
-    const viewKey = wire?.viewKey
     const erased: ErasedDefinition = {
       key: definition.key,
       stateSchema: definition.stateSchema,
@@ -271,11 +252,7 @@ export class SessionProjectionRegistry extends Service {
       apply: (state, event) => definition.apply(state as S, event),
       wire: wire === undefined
         ? undefined
-        : {
-          viewSchema: wire.viewSchema,
-          view: state => wire.view(state as S),
-          ...(viewKey === undefined ? {} : { viewKey: (state: unknown) => viewKey(state as S) }),
-        },
+        : { viewSchema: wire.viewSchema, view: state => wire.view(state as S) },
       stateVersion: definition.stateVersion,
     }
     if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) {
@@ -659,15 +636,14 @@ export class SessionProjectionRegistry extends Service {
       cell.state = next
       cell.observedSeq = event.seq
       if (changed && registration.def.wire !== undefined && this.listeners.size > 0) {
-        // Identity gate on the unit's comparison token, computed from the
-        // two states in hand: identical tokens mean the served view did not
-        // change, so a unit can buffer working fields without spamming the
-        // feed and `view` runs only for an actual push. Nothing is stored,
-        // so no comparison value exists to go stale across listener
-        // generations.
-        const keyOf = registration.def.wire.viewKey ?? registration.def.wire.view
-        if (Object.is(keyOf(previous), keyOf(next))) continue
-        const value = registration.def.wire.viewSchema.parse(registration.def.wire.view(next))
+        // Identity gate on the raw view, computed from the two states in
+        // hand: a changed state whose projection is reference-identical to
+        // the previous state's stays quiet, so a unit can buffer working
+        // fields without spamming the feed. Nothing is stored, so no
+        // comparison value exists to go stale across listener generations.
+        const raw = registration.def.wire.view(next)
+        if (Object.is(registration.def.wire.view(previous), raw)) continue
+        const value = registration.def.wire.viewSchema.parse(raw)
         for (const listener of this.listeners) {
           listener(session, registration.def.key as Extract<keyof SessionProjectionMap, string>, value, event.seq)
         }

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

@@ -151,49 +151,6 @@ describe('SessionProjectionRegistry drive', () => {
     expect(ctx.sessionProjections.snapshot(session).values['test/buffered']).toEqual(['a', 'b'])
   })
 
-  it('compares by the declared viewKey token and runs view only for a push', async () => {
-    const { ctx, session } = await harness()
-    let viewCalls = 0
-    // A fresh-array view would push on every changed state under the default
-    // raw-view token; the declared token pins dedup to `marks` identity and
-    // keeps `view` off the quiet path entirely.
-    ctx.sessionProjections.register({
-      key: 'test/buffered',
-      stateSchema: z.object({ marks: z.array(z.string()), draft: z.string() }),
-      init: () => ({ marks: [], draft: '' }),
-      apply: (state, event) => {
-        if (event.type === 'test/mark') return { marks: event.data.marks, draft: '' }
-        if (event.type === 'turn/start') return { marks: state.marks, draft: `draft-${String(event.seq)}` }
-        return state
-      },
-      wire: {
-        viewSchema: z.array(z.string()),
-        view: (state) => {
-          viewCalls += 1
-          return [...state.marks]
-        },
-        viewKey: state => state.marks,
-      },
-      stateVersion: 1,
-    })
-    const seen: { value: unknown; seq: number }[] = []
-    ctx.sessionProjections.onChanged((_session, key, value, seq) => {
-      if (key === 'test/buffered') seen.push({ value, seq })
-    })
-    const first = mark(session, ['a'])
-    expect(viewCalls).toBe(1)
-    // Draft-only apply: state reference moves, the token does not — and the
-    // fresh-object view is never consulted.
-    session.append('turn/start', { turn: 1 })
-    expect(viewCalls).toBe(1)
-    const second = mark(session, ['a', 'b'])
-    expect(seen).toEqual([
-      { value: ['a'], seq: first.seq },
-      { value: ['a', 'b'], seq: second.seq },
-    ])
-    expect(viewCalls).toBe(2)
-  })
-
   it('keeps dedup honest across listener generations: a return to an old value after an unobserved change still fires', async () => {
     const { ctx, session } = await harness()
     // A primitive-valued view compares by value under Object.is (the title

+ 0 - 3
packages/session/session-turn-outline/src/projection.ts

@@ -133,8 +133,5 @@ export const turnOutlineProjectionDefinition = {
   wire: {
     viewSchema: turnOutlineEntriesSchema,
     view: state => state.turns,
-    // Draft-only applies replace the state object but reuse `turns`, so this
-    // token keeps the feed quiet until a turn-level commit.
-    viewKey: state => state.turns,
   },
 } satisfies ProjectionDefinition<'turnOutline', TurnOutlineState>

+ 0 - 5
packages/session/session-turn-outline/tests/projection.spec.ts

@@ -262,9 +262,4 @@ describe('turn outline projection unit', () => {
       },
     }, [], 0, session.header)).not.toThrow()
   })
-
-  it('keys change detection to the turns array identity, so draft-only applies stay quiet without a view call', () => {
-    const state = { turns: [{ turn: 1, seq: 1, prompt: 'p', response: '' }], draft: 'buffering' }
-    expect(turnOutlineProjectionDefinition.wire.viewKey(state)).toBe(state.turns)
-  })
 })