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:
+62
-31
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user