rfc: converge the projection contract on state-driven units, host-side push, and the command channel

Rewrites the proposed session-projection note to the settled architecture:
ProjectionDefinition (init/apply/view/stateVersion) replaces the opaque
get(agent) provider; the host is the only computation site (eager drive,
watermark cache, session/projection push frame); the client reduces to a
generic seq-guarded value store with zero per-domain code; plan selection
routes through the standard command channel ({name, args} structured
command/run, both plan RPCs retired, pending becomes a pure replay
quantity); the persisted projection cache (sessionId/key/stateVersion/
observedSeq/state rows) is the later cold-read phase; reverse scans and
absorber declarations are rejected for now. Chinese counterpart updated
per-section, pairing re-recorded.
This commit is contained in:
imccyu
2026-07-27 20:53:55 +08:00
parent 4fcfcf32d5
commit f72f06e84a
3 changed files with 127 additions and 65 deletions

View File

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

View File

@@ -20,19 +20,28 @@ Four infrastructure pieces, then the domains become pure contributors.
### Whole-value event rule
A state-carrying log event MUST carry the complete post-change state, never a delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). Under this rule the client-side fold degenerates to **last-wins**: a domain's state is the whole value carried by the highest-seq domain event seen. No client-side state machine (goal's revision/CAS/phase checks stay at the host write path), no history dependence, out-of-order immunity by seq comparison, and self-healing a missed event is corrected by the next one.
A state-carrying log event MUST carry the complete post-change state, never a bare delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). The rule keeps every domain's transition trivially cheap (the framework drives it per event), keeps values self-describing on the wire, and lets any consumer treat the latest pushed value as final — out-of-order immunity by seq comparison, self-healing because a missed update is corrected by the next one.
### Host projection registry (`dsh-session-projection`, new package)
A light interface package: the merge-extensible type map, the registry service, zod at the boundary. Capability-seam three-way split: domain host plugins contribute, carriers consume, neither knows the other.
What a domain registers is a **state-driven computation unit** — three pure functions plus declarations — never an opaque getter. The framework owns driving it (subscription, watermark, caching, and later checkpointing); the domain owns only the mathematics. Projections serve every business domain (session title, plan, goal, permission, todos); commands are merely one trigger path and hold no special position in this contract.
```ts
export interface SessionProjectionMap {} // the single type table for the whole chain
export interface ProjectionProvider<K extends keyof SessionProjectionMap> {
export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the payload before it leaves the host
get(agent: Agent): SessionProjectionMap[K] // MUST be synchronous; whole current value
/** State for the empty log. */
init(): S
/** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
apply(state: S, event: SessionEvent): S
/** State → wire payload (the read-side projection). */
view(state: S): SessionProjectionMap[K]
/** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
stateVersion: number
}
declare module 'cordis' {
@@ -40,8 +49,10 @@ declare module 'cordis' {
}
```
- Values are wire JSON payloads; the same map typed end to end (host provider, wire block, client cell, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
- `get` runs against the host's full in-memory log (`agent.session.events`) — pagination exists only in the history slice returned to the client, never in the provider's view, so "the window lacks the event" cannot lose state on the host. A last-wins domain may backscan (bounded: first hit from the tail terminates; the events live in memory); a domain with an expensive fold keeps an incremental cache keyed by observed seq (goal's `GoalCache` is the template). Either way the provider returns the current whole value synchronously.
- Values are wire JSON payloads; the same map typed end to end (host unit, wire block, React hook) via `import type` — no second DTO table, no separate client-side "views" map. How a value is *rendered* is the slot system's business, never the projection layer's.
- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, stateVersion, observedSeq, stateJson)`. A row is never wrong, only possibly stale — `observedSeq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
- A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
- The package owns `./invariant` (every served key has a live registration).
@@ -57,23 +68,30 @@ The api-proxy history handler, after slicing the tail page, reads `session.seq`,
No new RPC method. The timing coincidence is exact: every moment the client needs a fresh baseline (open, reconnect resync, gap repair) already pulls the tail page, and the only path that never needs one (loadOlder) is the only path that passes `beforeSeq`. The client therefore has **no** independent "refetch the baseline" decision at all. Window content is never a signal: "no domain event in the window" is unanswerable there by construction, and only the baseline answers it.
Retired by this block: `session.planMode` (read side; `setPlanMode` stays), `goals.get` (read side; the six mutation RPCs stay, their responses no longer feed state — the mux event arrives anyway), the `todos` rider field, and `backscanTodos` in api-proxy (moves into the todo domain's provider, in `tool-todo`).
Retired by this block: `session.planMode` and `setPlanMode` (both sides — plan selection goes through the standard command channel, see the plan section), `goals.get` (read side; the six mutation RPCs stay, their responses no longer feed state — the mux event arrives anyway), the `todos` rider field, and `backscanTodos` in api-proxy (moves into the todo domain's unit, in `tool-todo`).
### Client: session-scope event dispatch and projection cells
### Push frame and the client value store (domains write zero client code)
The client runtime `Session` object gains a dispatch seam at its two event entrances — `appendLive(event)` (live signal) and `installWindow(…)` (window-replace signal, plus baseline reset when the response carries a projections block). Live and window-replace are distinguishable signals: that distinction is what #527 hand-rolled to avoid refetch storms and #587 hand-rolled to re-scan replacement windows. The core class returns to pure transcript concerns; the domain switches leave `applyEventSideEffects`.
Domain client plugins register **projection cells** at scope materialization (the `InputHub.shellFor` pattern; teardown rides the scope fiber):
Because the host is the only computation site, finished values reach clients over one new mux frame:
```ts
export interface ProjectionCellSpec<K extends keyof SessionProjectionMap> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the baseline at the wire boundary
fromEvent(event: SessionEvent): SessionProjectionMap[K] | undefined // whole value, or not-my-event
}
// MuxFrame union + schema branch:
{ type: 'session/projection', sessionId, key: string, value: unknown, seq: number }
```
Framework semantics, implemented once for all cells: a `lastAppliedSeq` watermark initialized from the baseline's `asOfSeq`; one application rule — `event.seq > watermark` and `fromEvent` hit ⇒ take the whole value, raise the watermark, `markDirty` (Notifier batching); live and window-replace events pass the same filter, so replayed old pages are dropped by seq and can never roll state back; a baseline reset re-seeds value and watermark, and a key absent from the block marks the capability absent. All the per-domain fences (#587's three layers, #527's write revision) dissolve into this one seq rule. Plan's pending intent stays out of the log (turn-enclosure) but inside the projection value — the host's `planMode.get()` already returns exactly that shape; pending is not propagated to other tabs (accepted: it is the issuing tab's local "awaiting boundary" fact; other tabs see the commit event).
The framework emits it whenever a unit's state reference changes (`Object.is` gate above); `seq` is the unit's watermark at emission. This is live push state, never logged — the same posture as the tool-view `view` slot: replay recomputes on the host.
The client object layer keeps one **generic value store** per session: `key → { value, seq }`, seeded by the tail page's projections block and updated by the frame, under the single rule **higher seq wins**. Replayed baselines cannot roll a newer frame back; a lost frame costs staleness until the next frame or baseline, never wrongness. No `fromEvent`, no per-domain cell registration, no client-side domain folding — a domain ships projection support with **zero client code** (the `SessionProjectionMap` merge serves both sides through the `/types` outlet). The bespoke `session/title` frame and the manager's title-snapshot map retire into this generic pair. All the per-domain fences (#587's three layers, #527's write revision) dissolve into the one seq rule.
### Plan through the standard command channel (worked example)
Plan mode demonstrates the full pattern — trigger path, run plane, and replay plane, cleanly separated:
- **Trigger path**: the web plan toggle sends `/plan` / `/plan off` through `command.execute` like any other command; the dedicated `setPlanMode`/`planMode` RPCs are retired. The user's *request* is durably recorded as that command's `command/run { name: 'plan', args: 'off' | '' }` — structured fields, no line parsing.
- **Run plane** (unchanged): the plan-mode service keeps its in-memory pending intent and flushes `plan/mode` at the next turn boundary. On cold start the service rebuilds its intent queue from the replay plane ("empty run state means the replay state").
- **Replay plane**: plan's projection unit folds **two** event types — its own `command/run` records set `wanted`; `plan/mode` sets `active` and clears `wanted`; `view` derives `{ active, pending: wanted !== null && wanted !== active }`. Pending is thereby a pure replay quantity: host restarts recover it, other tabs fold the same events (cross-tab pending for free), and a cold read answering `{ active: false, pending: true }` is accurate ("an unfulfilled selection awaits resume").
A domain's input event set is its own choice — that is the general rule this example instantiates. Whether "the user asked for X" appears in a projection (plan folds its command records) or only in the flow (the command node renders anyway) is per-domain semantics, never a framework concern.
### React: `useProjection`, the fifth framework hook seat
@@ -88,7 +106,7 @@ type UseProjection = {
}
```
`undefined` uniformly means capability absent (host plugin unmounted, client plugin unmounted, or baseline not yet landed). Cells expose bare `{subscribe, getSnapshot}`; `bindSnapshotSelector` with per-cell caching does the rest — reference stability holds because whole values are frozen event data, identical between events. Write paths are unchanged: mutation callbacks stay in the inject share (callbacks out of inject, live state out of `useProjection`).
`undefined` uniformly means capability absent (host plugin unmounted, or no baseline/frame has carried the key). The value store exposes bare per-key `{subscribe, getSnapshot}` faces; `bindSnapshotSelector` with per-key caching does the rest — reference stability holds because a key's value reference changes only when a frame or baseline lands. Write paths are unchanged: mutation callbacks stay in the inject share (callbacks out of inject, live state out of `useProjection`).
The one existing violation of "no hooks through inject" — `DetailsInjected.useSelection` — is folded in with this change: selection is viewing state living in the chat store, so the details registration declares the shared store handle and the component reads `props.useStore(s => s.selection)`; `useSelection` leaves the inject contract.
@@ -97,30 +115,39 @@ The one existing violation of "no hooks through inject" — `DetailsInjected.use
Two log-only (non-surface, model-invisible) events, mirroring the `tool/call`/`tool/result` pairing:
```ts
'command/run': { commandId: string; name: string; line: string; source: CommandSource }
'command/run': { commandId: string; name: string; args: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```
Both merged into `OutOfBandSessionEventMap`. The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged.
Both merged into `OutOfBandSessionEventMap`. The host command executor (`packages/ui/commands`) appends `command/run` before invoking the handler and `command/done` at settlement; on an idle log the pair rides a zero-step turn wrap (`TurnTriggerMap 'command'`) so turn enclosure holds without a model request. The payload is structured — `name` and `args` are the parser's own split (`parseCommand`'s name and rawInput), so a consumer (a projection unit folding its own command records, a rich command card) never re-parses a line. `text` is the handler's verbatim outcome — factual data of the same nature as `tool/result.content`, not presentation (how it is laid out remains client-computed at render time, satisfying the "presentation never enters the log" red line). Domains that want the model to know the outcome keep doing what they do today (plan's narration, goal's inject) — that is a domain decision, unchanged.
Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to pure admission (matched or not, syntax errors back to the composer immediately); the one-shot notice channel (`runDetached``noticeFor`) is retired.
Because committed events broadcast on the mux stream, refresh persistence, multi-tab sync, and fork/resume recovery all come for free. The `command.execute` RPC degrades to admission `{ matched, commandId? }`: whether the line resolved, and the minted pairing id when it did, so the issuing client can correlate its request with the flow node the lifecycle events produce. The one-shot notice channel (`runDetached``noticeFor`) is retired.
The client flow builder gains one generic command node (run/done paired by `commandId`; cross-window cuts soft-fall like tool pairs). Rendering goes through a new keyed slot `'conversation.chat.commandview'`, key = command name, **fallback = a generic command card** (zero registration required — the former notice text now renders durably in the flow). A domain upgrades by registering one row component, drawing on `command/run.line` and its own cell state — the same shape as tool rows after the toolview dissolution.
The client flow builder gains one generic command node (run/done paired by `commandId`; cross-window cuts soft-fall like tool pairs). Rendering goes through a new keyed slot `'conversation.chat.commandview'`, key = command name, **fallback = a generic command card** (zero registration required — the former notice text now renders durably in the flow). A domain upgrades by registering one row component, drawing on `command/run`'s structured fields and its own projection value (`useProjection`) — the same shape as tool rows after the toolview dissolution.
## Delivery plan
Infrastructure first; the three in-flight PRs are left untouched and re-target after the base lands (their migration mapping is the guide):
1. **Host base**: `dsh-session-projection` + api-proxy projections block. Mergeable with zero domains registered (block simply absent).
2. **Client base**: dispatch seam + cell framework + `useProjection` seat + the `useSelection` fold-in. Parallel with 1 (fixtures feed synthetic baselines).
3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement. Parallel with 1.
4. **Domain re-targets** (after 1+2): todo first (smallest: provider in `tool-todo`, cell from `todo/write`, drop the rider field), then plan (drop the unary and the fences), then goal (drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
1. **Host base**: `dsh-session-projection` (unit contract, eager drive, watermark cache) + api-proxy projections block + the `session/projection` push frame. Mergeable with zero domains registered (block and frames simply absent).
2. **Client base**: the generic value store + `useProjection` seat; retire the per-domain cell machinery and, with title's unit registered, the `session/title` frame and title-snapshot map. Depends on 1 for the frame shape (fixtures feed synthetic frames meanwhile).
3. **Command channel**: the two events, executor logging, generic node + keyed slot, notice retirement, `{matched, commandId?}` admission. Parallel with 1.
4. **Domain re-targets** (after 1+2): todo (unit in `tool-todo`, drop the rider field), then plan (two-event unit, RPCs retired, toggle → `/plan`), then goal (`goal/change` unit, drop `goals.get`, move the six `Session` methods into the domain plugin's inject).
5. **Persisted projection cache** (later phase, after the domain-KV storage seam): the `(sessionId, key, stateVersion, observedSeq, state)` rows, throttled writes with turn/end + detach mandatory points, and the persistence read-from-seq primitive for cold tail replay.
## Alternatives considered
**A dedicated `session.projections` RPC** — rejected: baseline-refresh moments coincide exactly with tail-page pulls, so a separate unary buys a second round-trip, a second seq to reconcile, and a client-side "when to refetch" decision that the rider design deletes outright.
**Naming the seam `registerFold`** — rejected: `get` does not promise a fold (goal reads a cache, plan overlays un-logged pending intent from service memory); `fold*` in this repo names pure `(events) => state` functions and the registry would dilute that. Projection is the event-sourcing term for exactly this read-model role, and both #587's note title and #497's comments already use it.
**An opaque `get(agent)` provider contract** — rejected after being the first draft: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — `get` has nothing to run against), or resume from a mid-log position. Registering the `(init, apply, view)` unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit.
**A live-only overlay hook (`live?(agent, base)`) for plan's pending intent** — rejected: it existed solely because the user's plan *selection* was not in the log. Routing the selection through the standard command channel puts `command/run` on the account, pending becomes a pure replay quantity, and the projection contract stays exactly three pure functions.
**Naming the seam `registerFold`** — superseded by the unit contract: the registered object now genuinely is a fold, but `fold*` in this repo names pure `(events) => state` helper functions while this seam registers a keyed, schema'd, versioned unit. Projection remains the event-sourcing term for the read-model role, and both #587's note title and #497's comments already use it.
**Client-side folding (per-domain projection cells with a `fromEvent`)** — rejected after being the second draft: once plan's unit folds two event types, a client cell must duplicate the host's transition logic in the browser — the same fold written twice, evolving separately. Pushing finished values (the title-frame precedent, generalized) keeps one computation site and reduces the client to a generic seq-guarded value store; domains write zero client code.
**Bounded reverse scan over the log tail (absorber declarations)** — rejected for now: nothing supports it today, it only serves domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row + forward tail replay — the same recipe as the client's baseline + catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve.
**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: it exists only to serve delta events. The whole-value rule makes every domain last-wins; goal's refetch loop, its coalescing, and its stale-read fence all disappear.
@@ -130,22 +157,26 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
**Event-broadcast collection instead of a registry walk** — rejected: async listeners cannot yield the single synchronous cut that makes `asOfSeq` one consistent snapshot across all keys; registries are this repo's shape for contributions (`ctx.tools`, prompt sections, slots).
**Propagating plan's pending intent across tabs** — deferred, not designed in: pending is deliberately un-logged (turn enclosure), a live non-logged control frame (the `session/queued` precedent) can add it later without touching this model.
**A dedicated `plan/select` selection event (structured domain event instead of folding command records)** — rejected in favor of the command channel: `command/run`'s structured `{name, args}` already records the selection, the `/plan` grammar and its fold live in the same plugin (domain-internal coupling, not cross-domain), and one less event type. The handler must call `set()` before any failable path so the logged request and the run plane cannot diverge — a domain-internal ordering constraint, documented at the handler.
**Keeping `setPlanMode` as a dedicated RPC** — rejected: plan selection is a user command like any other; the command channel gives it durable recording, flow rendering, multi-tab visibility, and admission semantics without a bespoke wire method. Web UI affordances (a toggle) compose the command line internally.
**Making mutation RPC responses feed cell state** — rejected: the committed mux event arrives immediately and carries the same whole value with a seq; responses feeding state is what required #527's write-revision fence.
## Acceptance criteria
- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host `register`, one client cell registration, and inject callbacks — no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files beyond its own `SessionProjectionMap` merge.
- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host unit `register`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files.
- The history tail page carries `projections` with `asOfSeq` equal to the window tail seq; loadOlder pages never carry it; a deployment without the registry serves histories without the block and clients treat every key as absent.
- Replayed window events cannot regress cell state (watermark test); a baseline landing after a newer mux commit cannot overwrite it (seq rule test).
- A stale baseline cannot overwrite a newer `session/projection` frame, and a replayed frame cannot regress the value store (higher-seq-wins tests on both paths).
- A slash command executed on one tab renders a durable node in the flow on refresh, on a second tab, and after resume; unregistered commands render the generic card; the composer notice path for command outcomes is gone.
- `useProjection` reaches components through the standard props kit; no hook crosses an inject contract (including `useSelection`).
- Session titles ride the generic pair (baseline block + projection frame); the bespoke `session/title` frame and the client title-snapshot map are gone.
## Risks
- **Whole-value rule is load-bearing**: a future domain logging deltas breaks last-wins silently. Mitigation: the rule is stated here and in the projection package README; cell `fromEvent` signatures make delta shapes unrepresentable without deliberate effort.
- **Synchronous `get` discipline**: a provider that awaits would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest.
- **Whole-value rule is load-bearing**: a future domain logging bare deltas cannot serve consumers from its latest event and complicates its own unit. Mitigation: the rule is stated here and in the projection package README; the unit contract makes the full state explicit at every transition.
- **Synchronous unit discipline**: `init`/`apply`/`view` that await would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest.
- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Units are cheap per-event by construction (whole-value rule), non-matching events return the same reference, and the count of registered domains is small; if a hot path ever shows, per-unit event-type prefilters can be added without contract change.
- **Projection payload growth**: every tail page carries every registered key. Payloads are whole values of UI-scale state (a todo list, a goal snapshot); if a future domain's value is large, per-key opt-out or lazy keys can be added to the request without changing the model.
- **Command log volume**: two log-only events per slash command; bounded by human command frequency, negligible against chunk volume.
- **Re-target churn**: three open PRs rebase onto a moved foundation. Accepted cost of infrastructure-first; the migration mapping section in the design ledger names each PR's keep/drop list.

View File

@@ -10,7 +10,7 @@ Status: proposed
- **客户端核心类吸收每一个领域。** 三者都往客户端运行时的 `Session` 类里添加私有字段、拉取编排和事件 switch 分支,并经 `ConversationSnapshot` 投出各自的值。仅 plan 一家就加了七个私有字段和三层栅栏请求版本、事件版本、最新活值缓存goal 加了写 revision 栅栏外加一个合并式重取循环todo 加了一个投影projection字段和一条事件 case 分支。再来第四个领域,就要第四次改动核心类。
- **三条基线通道。** todo 搭在历史尾页的 `todos` 字段上——由 **api-proxy 内部**的 `backscanTodos` 计算业务折叠fold逻辑寄居在载体里plan 加了一个专用的 `session.planMode` 一元 RPCgoal 加了 `goals.get`。同一个问题三种协议格式wire format
- **命令结果不可恢复。** `/goal``/plan` 以及其余所有斜杠命令都只在 `command.execute` RPC 响应里返回结果,以一条转瞬即逝的 composer 通知呈现在发起命令的标签页上。会话日志里什么也留不下:刷新、另开标签页、恢复resume或 fork 都会丢掉「该命令曾经运行过」的记录。领域*状态*变更是持久的goal 提交 `goal/change` 元数据plan 提交 `plan/mode`),但命令调用本身及其结论不是。
- **命令结果不可恢复。** `/goal``/plan` 以及其余所有斜杠命令都只在 `command.execute` RPC 响应里返回结果,以一条转瞬即逝的 composer 通知呈现在发起命令的标签页上。会话日志里什么也留不下:刷新、另开标签页、恢复或 fork 都会丢掉「该命令曾经运行过」的记录。领域*状态*变更是持久的goal 提交 `goal/change` 元数据plan 提交 `plan/mode`),但命令调用本身及其结论不是。
底层缺口是架构性的:客户端没有一个 seam 让插件在会话 scope 内观察会话事件并维护自己的派生状态host 侧也没有统一的方式把日志派生状态的当前值交给客户端——而该状态的历史可能已被分页挤出客户端窗口之外。
@@ -20,19 +20,28 @@ Status: proposed
### 全量值事件规则
携带状态的日志事件必须携带变更后的完整状态,绝不携带增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。该规则下,客户端侧的折叠退化为 **last-wins**:一个领域的状态,就是已见 seq 最高的该领域事件所携带的全量值。无需客户端状态机goal 的 revision/CAS/阶段检查留在 host 侧写路径),不依赖历史,靠 seq 比较获得乱序免疫,且自愈——漏掉的事件会被下一个事件纠正。
携带状态的日志事件必须携带变更后的完整状态,绝不携带增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。该规则让每个领域的状态转移始终足够廉价(框架逐事件驱动它),让值在协议层自描述,并让任何消费方都可以把最近推送的值当作最终值——靠 seq 比较获得乱序免疫,且自愈漏掉的更新会被下一次更新纠正。
### host 侧投影注册表(`dsh-session-projection`,新包)
一个轻量的接口包packagemerge-extensible 类型表、注册表服务、边界上的 zod 校验。能力 seam 三方拆分:领域 host 插件负责贡献,载体负责消费,两侧互不相识。
领域注册的是一个**状态驱动计算单元state-driven computation unit**——三个纯函数外加若干声明——绝不是一个不透明的 getter。驱动它是框架的职责订阅、水位线watermark、缓存以及后续的检查点机制领域只负责数学本身。投影服务于所有业务领域会话标题、plan、goal、权限、todos命令只是其中一条触发路径在本契约中没有任何特殊地位。
```ts
export interface SessionProjectionMap {} // the single type table for the whole chain
export interface ProjectionProvider<K extends keyof SessionProjectionMap> {
export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the payload before it leaves the host
get(agent: Agent): SessionProjectionMap[K] // MUST be synchronous; whole current value
/** State for the empty log. */
init(): S
/** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
apply(state: S, event: SessionEvent): S
/** State → wire payload (the read-side projection). */
view(state: S): SessionProjectionMap[K]
/** State must be plain JSON (persisted-cache precondition); bump to invalidate persisted rows. */
stateVersion: number
}
declare module 'cordis' {
@@ -40,8 +49,10 @@ declare module 'cordis' {
}
```
- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通host 提供方、协议块、客户端 cell、React 钩子)——没有第二张 DTO 表也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
- `get` 面向 host 的全量内存日志(`agent.session.events`)运行——分页只存在于返回给客户端的历史切片里,绝不出现在提供方的视野中,所以「窗口里缺这个事件」在 host 侧不可能丢状态。last-wins 领域可以回扫(有界:从尾部起首个命中即终止;事件本就在内存里);折叠开销大的领域维护一份以已见 seq 为键的增量缓存goal 的 `GoalCache` 即范本)。无论哪种方式,提供方都同步返回当前全量值
- 值就是协议层的 JSON 载荷;同一张类型表经 `import type` 端到端贯通host 侧单元、协议块、React 钩子)——没有第二张 DTO 表也没有独立的客户端「views」表。值如何*渲染*是 slot 体系的事,永远不归投影层管。
- **host 是投影唯一的计算地点。** 框架正向驱动eager drive每个已注册的单元每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧。这消除了双重实现陷阱plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存persisted projection cache**:形如 `(sessionId, key, stateVersion, observedSeq, stateJson)` 的行。一行永远不会是错的,至多是陈旧的——`observedSeq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题变成一次索引读至多外加一小段尾部回放session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach由活转冷的时刻。两次写入之间崩溃的代价是尾部回放更长一些绝不会是值出错。
- 领域的输入事件集由领域自己选择todos 只折叠 `todo/write`plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
- 注册是 effectdisposer 随 fiber 走):插件卸载后其 key 从后续响应中消失客户端将其读作能力缺失——HMR热模块替换语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
- 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
@@ -57,23 +68,30 @@ api-proxy 的历史处理器切出尾页后读取 `session.seq`,然后同步
不新增 RPC 方法。时机上的重合是精确的客户端每一个需要新基线的时刻打开、重连重同步、缺口修补本来就要拉尾页而唯一永远不需要基线的路径loadOlder恰好是唯一传 `beforeSeq` 的路径。因此客户端**完全没有**独立的「重取基线」决策。窗口内容从不充当信号:「窗口里没有该领域的事件」这个问题在窗口内从构造上就无法回答,只有基线能回答它。
随此块下线的旧通道:`session.planMode`(读侧;`setPlanMode` 保留)、`goals.get`(读侧;六个变更 RPC 保留但其响应不再喂状态——mux 事件反正会到)、`todos` 搭载字段,以及 api-proxy 里的 `backscanTodos`(移入 todo 领域的提供方,落在 `tool-todo`)。
随此块下线的旧通道:`session.planMode``setPlanMode`读写两侧——plan 选择改走标准命令通道,见 plan 一节)、`goals.get`(读侧;六个变更 RPC 保留但其响应不再喂状态——mux 事件反正会到)、`todos` 搭载字段,以及 api-proxy 里的 `backscanTodos`(移入 todo 领域的单元,落在 `tool-todo`)。
### 客户端:会话 scope 的事件分发与投影 cell
### 推送帧与客户端值仓(领域零客户端代码)
客户端运行时的 `Session` 对象在它的两个事件入口——`appendLive(event)`(实时信号)与 `installWindow(…)`(窗口替换信号,响应携带 projections 块时附带基线重置)——获得一个分发 seam。实时与窗口替换是可区分的两种信号#527 为避免重取风暴手工造出的、#587 为重扫替换窗口手工造出的,正是这个区分。核心类回归纯 transcript文本记录关切各领域的 switch 分支撤出 `applyEventSideEffects`
领域客户端插件在 scope 物化时注册**投影 cell**(即 `InputHub.shellFor` 模式;销毁随 scope fiber 走):
既然 host 是唯一计算地点,成品值经一个新的 mux 帧送达客户端:
```ts
export interface ProjectionCellSpec<K extends keyof SessionProjectionMap> {
key: K
schema: ZodType<SessionProjectionMap[K]> // validates the baseline at the wire boundary
fromEvent(event: SessionEvent): SessionProjectionMap[K] | undefined // whole value, or not-my-event
}
// MuxFrame union + schema branch:
{ type: 'session/projection', sessionId, key: string, value: unknown, seq: number }
```
框架语义对所有 cell 只实现一次:一条从基线 `asOfSeq` 初始化的 `lastAppliedSeq` 水位线watermark唯一一条应用规则——`event.seq > watermark``fromEvent` 命中 ⇒ 取全量值、抬高水位线、`markDirty`Notifier 批处理);实时事件与窗口替换事件过同一道过滤,所以重放的旧页按 seq 被丢弃,永远不可能把状态往回滚;基线重置会重设值与水位线,块中缺席的 key 则把对应能力标记为缺失。所有按领域自造的栅栏(#587 的三层、#527 的写 revision都消融进这一条 seq 规则。plan 的待定意图不入日志turn-enclosure但在投影值之内——host 的 `planMode.get()` 返回的恰是这个形状;待定态不向其他标签页传播(已接受:它是发起标签页本地的「等待边界」事实;其他标签页看到的是提交事件)
只要某单元的状态引用发生变化(上文的 `Object.is` 闸门),框架就发出该帧;`seq` 是发出时该单元的水位线。这是实时推送状态,绝不入日志——与 tool-view 的 `view` slot 同一姿态:回放时在 host 重新计算
客户端对象层为每个会话维护一个**通用值仓value store**`key → { value, seq }`,由尾页的 projections 块播种、由该帧更新,唯一规则是 **seq 高者胜**。重放的基线无法把更新的帧往回滚;丢失一个帧的代价只是陈旧——到下一个帧或基线为止——绝不会出错。没有 `fromEvent`,没有按领域的 cell 注册,没有客户端侧领域折叠——领域交付投影支持只需**零客户端代码**`SessionProjectionMap` merge 经 `/types` 出口同时服务两侧)。专设的 `session/title` 帧与 manager 的标题快照表都收编进这对通用机制。所有按领域自造的栅栏(#587 的三层、#527 的写 revision都消融进这一条 seq 规则。
### plan 走标准命令通道(完整示例)
plan mode 完整演示了这套模式——触发路径、运行面、回放面,三者干净分离:
- **触发路径**web 的 plan 开关像任何其他命令一样经 `command.execute` 发送 `/plan` / `/plan off`;专设的 `setPlanMode`/`planMode` RPC 下线。用户的*请求*被持久记录为该命令的 `command/run { name: 'plan', args: 'off' | '' }`——结构化字段,无需解析行文本。
- **运行面**不变plan-mode 服务在内存里保持待定意图,并在下一个轮次边界落下 `plan/mode`。冷启动时服务从回放面重建其意图队列(「运行态为空即以回放态为准」)。
- **回放面**plan 的投影单元折叠**两**种事件——它自己的 `command/run` 记录设置 `wanted``plan/mode` 设置 `active` 并清除 `wanted``view` 推导出 `{ active, pending: wanted !== null && wanted !== active }`。待定态由此成为纯回放量host 重启能恢复它,其他标签页折叠同样的事件(跨标签页待定态随之自动获得),冷读回答 `{ active: false, pending: true }` 也是准确的(「一个未兑现的选择正等待恢复」)。
领域的输入事件集由领域自己选择——本示例落实的正是这条一般规则。「用户请求过 X」是出现在投影里plan 折叠自己的命令记录),还是只出现在 flow 里(命令节点反正会渲染),属于各领域自己的语义,永远不是框架的关切。
### React`useProjection`,第五个框架钩子席位
@@ -88,7 +106,7 @@ type UseProjection = {
}
```
`undefined` 统一表示能力缺失host 插件未挂载、客户端插件未挂载或基线尚未到达。cell 只暴露裸的 `{subscribe, getSnapshot}`;其余交给带逐 cell 缓存的 `bindSnapshotSelector`——引用稳定性成立,因为全量值是冻结的事件数据,两次事件之间恒等不变。写路径不变:变更回调留在 inject 共享面(回调出自 inject活状态出自 `useProjection`)。
`undefined` 统一表示能力缺失host 插件未挂载,或尚无任何基线/帧携带过该 key。值仓只暴露按 key 的裸 `{subscribe, getSnapshot}`;其余交给带逐 key 缓存的 `bindSnapshotSelector`——引用稳定性成立,因为一个 key 的值引用只在帧或基线落地时才变化。写路径不变:变更回调留在 inject 共享面(回调出自 inject活状态出自 `useProjection`)。
「钩子不得穿过 inject」的唯一既有违例——`DetailsInjected.useSelection`——随本变更一并收编:选中态是住在聊天 store 里的查看状态,因此 details 注册声明共享 store 句柄,组件改读 `props.useStore(s => s.selection)``useSelection` 退出 inject 契约。
@@ -97,30 +115,39 @@ type UseProjection = {
两个仅日志(非 surface、模型不可见事件镜像 `tool/call`/`tool/result` 的配对:
```ts
'command/run': { commandId: string; name: string; line: string; source: CommandSource }
'command/run': { commandId: string; name: string; args: string; source: CommandSource }
'command/done': { commandId: string; kind: 'success' | 'error'; text?: string }
```
两者都合并进 `OutOfBandSessionEventMap`。host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done``text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据不是呈现版式如何编排仍由客户端在渲染时计算满足「呈现永不入日志」这条红线。想让模型知道结果的领域继续做它们今天在做的事plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。
两者都合并进 `OutOfBandSessionEventMap`。host 侧命令执行器(`packages/ui/commands`)在调用处理器前追加 `command/run`,在结算时追加 `command/done`;日志空闲时这对事件搭乘一个零步骤轮次包裹(`TurnTriggerMap 'command'`使轮次封闭turn enclosure在没有模型请求的情况下依然成立。载荷是结构化的——`name``args` 就是解析器自己的切分(`parseCommand` 的 name 与 rawInput因此消费方折叠自己命令记录的投影单元、富命令卡片永远无需重新解析行文本`text` 是处理器的原样结果——与 `tool/result.content` 同一性质的事实数据不是呈现版式如何编排仍由客户端在渲染时计算满足「呈现永不入日志」这条红线。想让模型知道结果的领域继续做它们今天在做的事plan 的旁白、goal 的注入)——那是领域自己的决定,保持不变。
由于已提交事件会在 mux 流上广播刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定(是否匹配命中、语法错误立即打回 composer一次性通知通道(`runDetached``noticeFor`)就此下线。
由于已提交事件会在 mux 流上广播刷新后仍在、多标签页同步、fork/恢复后可还原这三件事随之全部自动获得。`command.execute` RPC 退化为准入判定——`{ matched, commandId? }`:该行是否匹配命中,以及命中时新铸的配对 id发起命令的客户端据此把自己的请求与生命周期事件产出的 flow 节点关联起来。一次性通知通道(`runDetached``noticeFor`)就此下线。
客户端 flow 构建器新增一个通用命令节点run/done 按 `commandId` 配对;跨窗口截断时与工具配对同样软降级)。渲染走一个新的 keyed slot `'conversation.chat.commandview'`key = 命令名,**兜底 = 通用命令卡片**(零注册即可用——从前的通知文本现在持久地渲染在 flow 里)。领域要升级展示,只需注册一个行组件,取材于 `command/run.line` 与自己的 cell 状态——与 toolview 解散之后的工具行同一形状。
客户端 flow 构建器新增一个通用命令节点run/done 按 `commandId` 配对;跨窗口截断时与工具配对同样软降级)。渲染走一个新的 keyed slot `'conversation.chat.commandview'`key = 命令名,**兜底 = 通用命令卡片**(零注册即可用——从前的通知文本现在持久地渲染在 flow 里)。领域要升级展示,只需注册一个行组件,取材于 `command/run` 的结构化字段与自己的投影值(`useProjection`——与 toolview 解散之后的工具行同一形状。
## Delivery plan
基础设施先行;三个在途 PRPull Request原样不动待基座落地后重新对接它们的迁移映射即指南
1. **host 基座**`dsh-session-projection` + api-proxy 的 projections 块。零领域注册也可合入(此时块直接缺席)。
2. **客户端基座**分发 seam + cell 框架 + `useProjection` 席位 + `useSelection` 收编。与 1 并行(fixture测试前置数据喂合成基线)。
3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线。与 1 并行。
4. **领域重新对接**(在 1+2 之后):先 todo最小:提供方`tool-todo`cell 取自 `todo/write`,删掉搭载字段),再 plan删掉一元 RPC 和各道栅栏),最后 goal删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject
1. **host 基座**`dsh-session-projection`(单元契约、正向驱动、水位线缓存)+ api-proxy 的 projections 块 + `session/projection` 推送帧。零领域注册也可合入(此时块与帧直接缺席)。
2. **客户端基座**通用值仓 + `useProjection` 席位;下线按领域的 cell 机制,并在标题单元注册后一并下线 `session/title` 帧与标题快照表。帧的形状依赖 1在此之前 fixture测试前置数据喂合成)。
3. **命令通道**:两个事件、执行器落日志、通用节点 + keyed slot、通知通道下线`{matched, commandId?}` 准入。与 1 并行。
4. **领域重新对接**(在 1+2 之后):先 todo单元`tool-todo`删掉搭载字段),再 plan双事件单元、RPC 下线、开关改发 `/plan`),最后 goal`goal/change` 单元,删掉 `goals.get`,把六个 `Session` 方法移入领域插件的 inject
5. **持久投影缓存**(后续阶段,待 domain-KV 存储 seam 就绪后):`(sessionId, key, stateVersion, observedSeq, state)` 行、带 turn/end 与 detach 强制点的节流写入,以及持久化侧供冷尾部回放用的按 seq 起读原语。
## Alternatives considered
**专设一个 `session.projections` RPC**——不予采纳:基线刷新时刻与尾页拉取精确重合,单独的一元 RPC 只会换来第二次往返、第二个待调和的 seq以及一个客户端「何时重取」决策——而搭载设计把这个决策整个删掉了。
**把 seam 命名为 `registerFold`**——不予采纳:`get` 并不承诺折叠goal 读缓存plan 从服务内存叠加未入日志的待定意图);本仓库里 `fold*` 专指纯 `(events) => state` 函数注册表会稀释这一命名。projection投影正是事件溯源中指称这种读模型角色的术语#587 的 Note 标题与 #497 的评论也都已在使用它
**不透明的 `get(agent)` 提供方契约**——曾是第一稿,后被否决:计算模型藏在领域内部时,框架永远无法为状态做检查点、无法服务冷会话(没有 agent、没有已加载的日志——`get` 无处可跑)、也无法从日志中段续算。注册 `(init, apply, view)` 单元把驱动权交给框架,领域只留纯数学;有 host 侧行为需求的领域,其服务订阅照旧自持,与投影单元互不牵连
**为 plan 待定意图专设的仅实时叠加钩子(`live?(agent, base)`**——不予采纳:它存在的唯一理由是用户的 plan *选择*不在日志里。让选择走标准命令通道后,`command/run` 上了账,待定态成为纯回放量,投影契约保持恰好三个纯函数。
**把 seam 命名为 `registerFold`**——已被单元契约取代:注册对象如今确实是一个折叠,但本仓库里 `fold*` 专指纯 `(events) => state` 辅助函数,而该 seam 注册的是带 key、带 schema、带版本的单元。投影仍是事件溯源中指称读模型角色的术语#587 的 Note 标题与 #497 的评论也都已在使用它。
**客户端侧折叠(带 `fromEvent` 的按领域投影 cell**——曾是第二稿,后被否决:一旦 plan 的单元要折叠两种事件,客户端 cell 就必须在浏览器里复刻 host 的状态转移逻辑——同一个折叠写两遍、各自演化。推送成品值(标题帧先例的泛化)保住唯一计算地点,并把客户端简化为一个由 seq 把守的通用值仓;领域零客户端代码。
**对日志尾部的有界反向扫描absorber 声明)**——暂不采纳:今天没有任何东西需要它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行 + 正向尾部回放——与客户端的基线 + 追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。
**`invalidate` 式 cell标脏遇领域事件就重取**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-winsgoal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。
@@ -130,22 +157,26 @@ type UseProjection = {
**用事件广播收集、替代注册表遍历**——不予采纳:异步监听器给不出那个单一的同步切面,而正是它让 `asOfSeq` 成为横跨所有 key 的一致快照;注册表才是本仓库承接贡献的通行形状(`ctx.tools`、提示词片段、slot
**把 plan 的待定意图跨标签页传播**——推迟不纳入本设计待定态是刻意不入日志的turn enclosure一种实时的非日志控制帧先例 `session/queued`)日后可以在完全不动本模型的前提下补上它
**专设 `plan/select` 选择事件(用结构化领域事件替代折叠命令记录)**——不予采纳,改用命令通道:`command/run` 的结构化 `{name, args}` 已经记录了选择,`/plan` 的语法与其折叠逻辑同住一个插件(领域内耦合,非跨领域),还少一种事件类型。处理器必须在任何可能失败的路径之前调用 `set()`,使已入日志的请求与运行面不可能分叉——这是领域内部的顺序约束,文档写在处理器处
**保留 `setPlanMode` 专用 RPC**——不予采纳plan 选择就是一条普通的用户命令命令通道给它持久记录、flow 渲染、多标签页可见性与准入语义不需要专设协议方法。Web UI 的交互组件(一个开关)在内部拼出命令行即可。
**让变更 RPC 的响应喂 cell 状态**——不予采纳:已提交的 mux 事件即刻到达,携带同一个全量值外加 seq「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。
## Acceptance criteria
- 领域插件把按会话的日志派生状态送达 React只需写全量值事件声明、一次 host 侧 `register`一次客户端 cell 注册、以及 inject 回调——除自己那份 `SessionProjectionMap` merge 之外,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。
- 领域插件把按会话的日志派生状态送达 React只需写全量值事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。
- 历史尾页携带 `projections`,其 `asOfSeq` 等于窗口尾部 seqloadOlder 页永不携带;未装注册表的部署照常返回不带该块的历史,客户端把所有 key 视为缺席。
- 重放的窗口事件不能让 cell 状态倒退(水位线测试);在更新的 mux 提交之后才落地的基线不能覆盖该提交(seq 规则测试)。
- 陈旧的基线不能覆盖更新的 `session/projection` 帧,重放的帧也不能让值仓倒退(两条路径都做 seq 高者胜测试)。
- 在一个标签页执行的斜杠命令,刷新后、在第二个标签页上、恢复之后都在 flow 中渲染出持久节点;未注册的命令渲染通用卡片;命令结果的 composer 通知路径彻底移除。
- `useProjection` 经标准 props 套件抵达组件;没有任何钩子穿过 inject 契约(包括 `useSelection`)。
- 会话标题搭乘这对通用机制(基线块 + 投影帧);专设的 `session/title` 帧与客户端标题快照表彻底移除。
## Risks
- **全量值规则是承重结构**:未来某个领域若记增量事件,会无声地破坏 last-wins。缓解该规则写明在本 Note 与投影包的 README 里cell 的 `fromEvent` 签名使增量形状若非刻意为之便无从表达
- **同步 `get` 纪律**:提供方一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律invariant 配套在可行范围内断言同步性;其余由评审把关。
- **全量值规则是承重结构**:未来某个领域若只记裸增量,就无法凭其最新事件服务消费方,还会让自己的单元复杂化。缓解:该规则写明在本 Note 与投影包的 README 里;单元契约让完整状态在每次转移处都是显式的
- **单元的同步纪律**`init`/`apply`/`view` 一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律invariant 配套在可行范围内断言同步性;其余由评审把关。
- **忙碌会话上的正向驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。按构造,单元的逐事件开销很低(全量值规则),不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,契约不变。
- **投影载荷膨胀**:每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值(一张 todo 清单、一份 goal 快照);将来若某领域的值很大,可以在请求上加逐 key 的 opt-out 或惰性 key模型本身不用改。
- **命令日志体量**:每条斜杠命令两个仅日志事件;上限由人敲命令的频率决定,相对分片体量可忽略不计。
- **重新对接的返工**:三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价;设计台账中的迁移映射一节逐一列出每个 PR 的保留/删除清单。