@rindle/optimistic 0.4.4 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/backend.ts CHANGED
@@ -59,11 +59,15 @@ import type {
59
59
  ChangeEvent,
60
60
  ColsMap,
61
61
  ColType,
62
+ Condition,
63
+ CorrelatedSubquery,
62
64
  IsoTx,
63
65
  KeyedRow,
64
66
  Mutation,
67
+ MutationEnvelope,
65
68
  MutationGen,
66
69
  MutationOp,
70
+ MutationOutcomeFrame,
67
71
  MutatorCtx,
68
72
  NormalizedEvent,
69
73
  NormalizedOp,
@@ -82,6 +86,18 @@ import { aggTableSchemas, NormalizedSync, rewriteAggregates, type ColCounts, typ
82
86
  import { WasmBackend, type ServerDeltaOp, type WasmWriteTxn } from "@rindle/wasm";
83
87
 
84
88
  import { AggOverlay, type ChildOp, collectAggDefs } from "./agg-overlay.ts";
89
+ import {
90
+ decodeOutcomeRow,
91
+ LIFECYCLE_TABLE_SCHEMAS,
92
+ ROOM_CLIENT_MUTATIONS_TABLE,
93
+ ROOM_MUTATION_OUTCOMES_TABLE,
94
+ ROOM_WATERMARK_TABLE,
95
+ roomDomainKey,
96
+ SCOPE_SESSIONS_TABLE,
97
+ type SystemStreamSpec,
98
+ } from "./system-streams.ts";
99
+
100
+ export type { SystemStreamSpec, SystemStreamTable } from "./system-streams.ts";
85
101
 
86
102
  /** A keyed row: column name → cell. The ergonomic shape — column names are validated against the
87
103
  * schema at runtime, so a typo throws immediately with the valid names. Re-exported from
@@ -150,16 +166,123 @@ export type { ResultType };
150
166
  * are assigned by the `Store` starting at 1, so 0 never collides. */
151
167
  const LMID_QID: QueryId = 0;
152
168
 
169
+ /** One captured write, pk-granular (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3.2 #1: "the
170
+ * writers already receive `(table, row)`... this is pure capture, no semantic change"). `row` is
171
+ * the post-write image (positional wire cells, schema column order); `undefined` for a `remove` —
172
+ * no row survives it, and `row === undefined` stays the remove marker.
173
+ *
174
+ * `oldRow` is the PRE-IMAGE, captured for a `remove` AND an `edit` (H-ii): the full-width row
175
+ * read via `tx.get` immediately BEFORE the write staged — read-your-writes, so a write to the
176
+ * same pk earlier in the SAME invocation shows through — falling back to the caller's asserted
177
+ * old row when the pk is not txn-visible (a raw remove/edit of an absent row; incl. the
178
+ * pk-MOVING raw edit, whose record is keyed by the NEW pk yet whose pre-image is the caller's
179
+ * OLD row). A captured remove/edit thus always carries a full-width pre-image — with ONE
180
+ * exception: a record that collapses to a (re-)insert has none, see the matrix.
181
+ *
182
+ * Coalescing within one invocation is last-write-wins per pk on `row` (the final image matches
183
+ * the engine head's own semantics for that pk) with `oldRow` pinned to the TXN-ENTRY BASE — the
184
+ * H-ii matrix:
185
+ * - edit-after-add / edit-after-remove: the record collapses to a (re-)insert — post-image
186
+ * only, NO `oldRow` (the pk did not pre-exist this invocation's base; presence hold-back
187
+ * uses `row`).
188
+ * - edit-after-edit: keeps the FIRST pre-image (the txn-entry base — the chain nets to ONE
189
+ * edit from the base to the final image).
190
+ * - remove-after-edit / remove-after-remove: keeps the ORIGINAL pre-image (the first write's
191
+ * captured base), NOT the edited transient — the net effect is a remove of the row the
192
+ * external world last knew.
193
+ * - remove-after-add: keeps the txn-visible pre-image (the transient added row — the pk had
194
+ * no base, and this is the only truthful full-width row there is; G-iii pinned it).
195
+ * - add-after-remove (a re-insert): drops `oldRow` (presence hold-back uses `row`).
196
+ * Across rebase re-invocations the write-set is union-never-shrink ({@link mergeWriteSet}): a
197
+ * re-run that no-ops keeps the prior record — and its pre-image — intact. */
198
+ export interface WriteRecord {
199
+ table: string;
200
+ pk: WireValue[];
201
+ row: WireValue[] | undefined;
202
+ oldRow?: WireValue[];
203
+ }
204
+
205
+ /** The pk-granular write-set captured over ONE mutator invocation: table → pk-key (a stable-JSON
206
+ * encoding of the pk cells, {@link stableJson}) → that pk's write, LAST-WRITE-WINS within the
207
+ * invocation — an add-then-edit or edit-then-edit of the SAME pk collapses to its final image,
208
+ * matching the engine head's own semantics for that pk. Chosen (over a flat array) because the
209
+ * later routing proof needs "is pk P in the writable scope" / "did we already see this pk in this
210
+ * invocation" as cheap lookups, and rebase re-invocation needs to MERGE a fresh write-set into an
211
+ * accumulated one ({@link mergeWriteSet}) — both are Map operations, not scans.
212
+ *
213
+ * `touched` (the pre-existing table-granular `Set<string>` the pending axis reads, §7.2) is
214
+ * exactly `new Set(writeSet.keys())` — derived from this, never separately populated, so the two
215
+ * can never drift. */
216
+ export type WriteSet = Map<string, Map<string, WriteRecord>>;
217
+
218
+ /** Whether a recorded point read (`tx.get`/`tx.row`) found a row. */
219
+ export type ReadOutcome = "present" | "absent";
220
+
221
+ /** One recorded point read, pk-granular (§3.2 #2). Recording-mode only — see {@link ReadLog}.
222
+ * Since H-ii this covers BOTH the public reads (`tx.get`/`tx.row`) and the keyed writers'
223
+ * internal pre-existence probes (§3.2 #3 — see the `rawGet` note in {@link trackingTx}). */
224
+ export interface ReadRecord {
225
+ table: string;
226
+ pk: WireValue[];
227
+ outcome: ReadOutcome;
228
+ }
229
+
230
+ /** The read-log captured over ONE mutator invocation when recording is armed (RINDLE-REALTIME-
231
+ * QUERY-ENABLEMENT-DESIGN.md §3.2 #2): every point read (`reads` — the public `tx.get`/`tx.row`
232
+ * and, since H-ii, the keyed writers' internal pre-existence probes) plus every resolved query AST
233
+ * (`queries`, from `tx.query`). A SIBLING of the folded read TRAP (`FoldReadError` below) — the
234
+ * trap arms on the folded path and throws before any read completes (recording never runs there);
235
+ * recording arms on the ordinary (non-folded) prediction run and never throws. Pure capture for
236
+ * devtools/inspection (the §3 routing derivation it once fed was removed by
237
+ * 302-ROOM-STORE-SEPARATION-DESIGN.md §5 — mutators DECLARE their domain now). */
238
+ export interface ReadLog {
239
+ reads: ReadRecord[];
240
+ queries: Ast[];
241
+ }
242
+
153
243
  interface PendingMutation {
154
244
  /** The wire mutation id. A FOLDED entry carries `null` until its window flushes — the `mid`
155
245
  * is dealt from `nextMid` in SEND order, never reserved at invoke, so the wire sequence stays
156
246
  * gapless under debounce (FOLDED-MUTATIONS-DESIGN §4.1). A `null` entry is never confirmable
157
- * (the confirm-drop retains it) and re-invokes AFTER every assigned mid (sorted by `mid ?? ∞`). */
247
+ * (the confirm-drop retains it) and re-invokes AFTER every assigned mid. */
158
248
  mid: number | null;
249
+ /** The client-global deal sequence, stamped in the same breath as {@link mid} (`null` while the
250
+ * mid is). Mids are PER-DOMAIN (each authority numbers its own confirms, §7.1), so mids from
251
+ * different domains are incomparable — a daemon mid 5 and a room mid 1 say nothing about which
252
+ * was sent first. `seq` is the ONE total order across domains: **confirmation order is
253
+ * per-domain; replay order is client-global** — the reconcile's re-invocation sort keys on
254
+ * `seq`, never on `mid`. Within one domain `seq` order equals `mid` order (both dealt at the
255
+ * same send-time choke point) EXCEPT across an H-v deopt re-enqueue: a flipped entry keeps its
256
+ * ORIGINAL seq while its fresh daemon mid is dealt later, so its seq may undercut daemon
257
+ * entries with smaller mids. That is the point — seq is the REPLAY order and the flip must not
258
+ * move the entry's overlay position (a read-dependent sibling invoked after it replays on its
259
+ * value); the only consumer of the ordering is the seq-keyed reconcile sort, which wants
260
+ * exactly this. Single-domain behavior without deopts is unchanged. */
261
+ seq: number | null;
159
262
  name: string;
160
263
  args: unknown;
161
- /** Tables this mutator touched at its LAST invocation (drives the pending axis, §7.2). */
264
+ /** The confirming stream (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1): which domain's ledger
265
+ * dealt this entry's `mid` and whose confirm watermark can retire it. Resolved from the injectable
266
+ * `domainPolicy` when the mid is dealt (for a folded entry, re-resolved at flush). `"daemon"` in
267
+ * the single-domain configuration. An un-flushed fold (`mid == null`) carries its provisional
268
+ * domain but is never confirmable until the flush stamps a real mid. */
269
+ domain: string;
270
+ /** Tables this mutator touched at its LAST invocation (drives the pending axis, §7.2). Derived
271
+ * from `writes.keys()` — see {@link WriteSet}. */
162
272
  touched: Set<string>;
273
+ /** The pk-granular write-set captured at this entry's LAST invocation (§3.2 #1). A rebase
274
+ * re-invocation MERGES its fresh write-set into this one ({@link mergeWriteSet}), mirroring the
275
+ * `touched` union: the key set only grows across re-invocations (a re-run that no-ops must not
276
+ * shrink it, §7.2), each key's value is always the newest. Tables are ENGINE names: a
277
+ * room-domain entry's writes on the room's own tables record the namespaced name (302 §2). */
278
+ writes: WriteSet;
279
+ /** The read-log captured at this entry's LAST *recorded* invocation (§3.2 #2). Empty for a
280
+ * FOLDED entry (the trap, not recording, arms on that path — nothing is ever recorded there)
281
+ * and left as the ORIGINAL invoke's log across a rebase re-invocation: recording is armed only
282
+ * on the initial `invoke`, not the reconcile replay (a re-invocation runs against a rebased
283
+ * base, so its read outcomes would need re-proving against the NEW base anyway — re-arming
284
+ * recording there is future work, not required for pure capture). */
285
+ reads: ReadLog;
163
286
  }
164
287
 
165
288
  /** A virtual-clock seam for the fold debounce/maxWait timers (FOLDED-MUTATIONS-DESIGN §9): the
@@ -180,6 +303,15 @@ export interface FoldOptions {
180
303
  /** Hard cap so a never-idle drag still persists periodically (trailing throttle). Unbounded if
181
304
  * omitted — an idle gap of `debounceMs` is then the only thing that flushes. */
182
305
  maxWaitMs?: number;
306
+ /** §9.3 room-aware cadence. When this fold's write ROUTES INTO A ROOM (a collaborator is live on
307
+ * the shared head), flush at this (short) interval instead of `debounceMs`/`maxWaitMs`, so the
308
+ * intermediate frames STREAM to the room rather than collapsing to last-value-wins — the pen is
309
+ * watched, so its growth matters. OFF the room (solo / daemon-served) this is ignored and the
310
+ * caller's `debounceMs` collapse governs. `0` ⇒ per-frame (never coalesce while in a room); a
311
+ * small value (e.g. 40ms) animates while still capping the write rate. Absent ⇒ same cadence
312
+ * room or not (today's behavior). The room decision is probed from the write-set at the fold
313
+ * window's first invoke; it stays fixed for that window. */
314
+ roomDebounceMs?: number;
183
315
  /** Keep deferring across overlapping non-fold writes for maximum economy, accepting the §4.2
184
316
  * read-dependent reorder snap. Default `false` (flush-on-enqueue — correct-and-boring). */
185
317
  deferAcrossWrites?: boolean;
@@ -224,6 +356,89 @@ interface BufferedFrame {
224
356
  seq: number;
225
357
  }
226
358
 
359
+ /** ONE authority channel's coherence gate (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §5.1): the
360
+ * per-source generalization of what used to be the backend's single `(buffer, appliedCv)` pair.
361
+ * Each connected source — the daemon always; a `room:doc:X` once Slice G wires the live feed —
362
+ * buffers its own cv-tagged frames and releases on its OWN `cvMin`, an independent cycle feeding
363
+ * the one `applyRelease`: **coherent within a source; eventual across sources** (no joint
364
+ * barrier, by design). `key` doubles as the lmid fold domain (§7.1) and the physical source the
365
+ * release rebases (§5.3). `sync` is this source's OWN refcount/baseline space — folding two
366
+ * sources through one `NormalizedSync` would refcount an overlapping row 1→2 and emit NOTHING
367
+ * for the second source, silently starving its per-source baseline (§5.2 "a real store: its own
368
+ * baseline"). */
369
+ interface SourceGate {
370
+ key: string;
371
+ source: OptimisticSource;
372
+ sync: NormalizedSync;
373
+ buffer: BufferedFrame[];
374
+ /** Arrival counter behind {@link BufferedFrame.seq}, scoped to THIS gate's buffer. */
375
+ nextSeq: number;
376
+ appliedCv: number;
377
+ /** A ROOM gate's wire-table → engine-table map (302 §2 — one source per table): the channel's
378
+ * released deltas rename into the room's own namespaced tables, and a wire table NOT in the
379
+ * map (context the room still publishes, or an unknown table) is DROPPED — the daemon is the
380
+ * sole authority for context, so its copy must never enter the store from a room channel
381
+ * (302 §6). Absent on the daemon gate (its deltas apply verbatim). */
382
+ tableMap?: ReadonlyMap<string, string>;
383
+ }
384
+
385
+ /** One I-iv doorbell event ({@link OptimisticBackend.onScopeSessions}, §4.1): a release folded
386
+ * scope-session rows for `scope`, and `others` is the count of OTHER clients' unexpired sessions
387
+ * there — {@link OptimisticBackend.otherScopeSessions} evaluated at fold time (the same one rule,
388
+ * on the injectable {@link FoldClock}, so a virtual-clock harness gets deterministic verdicts).
389
+ * The consumer (client.ts) triggers its one debounced re-lease on the 0→≥1 transition; expired
390
+ * and own-clientID rows never count, so a solo client's own row can never ring its own bell. */
391
+ export interface ScopeSessionsEvent {
392
+ scope: string;
393
+ others: number;
394
+ }
395
+
396
+ /** One demoted room source's §4.2 SWAP-BACK gate record (Slice I-v, re-expressed by 302 §4.2):
397
+ * after a downgrade the room's namespaced tables keep backing their views — frozen at the room's
398
+ * last state (the channel is disconnected) — until the daemon plane has provably absorbed the
399
+ * room's final flush. Swapping the views back earlier would show the falling-back follower's
400
+ * PRE-flush images (a visibly rolled-back document). The drop condition is evaluated after every
401
+ * release ({@link OptimisticBackend.evaluateGhosts}):
402
+ *
403
+ * `roomWatermarks[doc] ≥ finalFlushSeq` (0 ⇒ trivially true — a never-flushed room)
404
+ * AND no pending mutation with `domain === sourceKey` remains (sent-pins-domain, §7.5 —
405
+ * room-domain entries retire ONLY through the outcome-resolved daemon-carried folds, I-iii).
406
+ *
407
+ * Both satisfied ⇒ {@link OptimisticBackend.dropGhost}: every room-swapped view re-registers on
408
+ * its ORIGINAL (daemon-table) AST — value-equal under the fence, so visually a no-op — and the
409
+ * room's namespaced tables unregister. */
410
+ interface RoomGhost {
411
+ doc: string;
412
+ finalFlushSeq: number;
413
+ /** Whether the ONE stuck-downgrade event already fired for this ghost. */
414
+ stuckReported: boolean;
415
+ }
416
+
417
+ /** The I-v stuck-downgrade event ({@link OptimisticBackend.onDowngradeStuck}): the ghost's fence
418
+ * is satisfied but these SENT room-domain mids never resolved (an entry that never reached the
419
+ * room — sent-but-undelivered when the socket died — is undecidable in general, §7.5). The ghost
420
+ * HOLDS (fail LOUD, never silent; no timeout-retire is invented) and the mids are surfaced once,
421
+ * actionably. */
422
+ export interface DowngradeStuckEvent {
423
+ sourceKey: string;
424
+ doc: string;
425
+ mids: number[];
426
+ }
427
+
428
+ /** The 302 §6.1 context-coverage event ({@link OptimisticBackend.onRoomContextJoin}): a view
429
+ * swapping onto room `sourceKey`'s namespaced tables still references `tables` the room does NOT
430
+ * own — those refs keep reading the PLAIN daemon tables (the client-side join across kinds), and
431
+ * the room's relayed copies of them are dropped by design (§6). Whether a daemon subscription
432
+ * covers the joined rows is unknowable here, so the condition is surfaced ONCE per view: without
433
+ * coverage the join renders silently empty for the whole room session. Fired at swap-in — a view
434
+ * whose every referenced table is room-owned (every in-repo app today) never fires it. */
435
+ export interface RoomContextJoinEvent {
436
+ sourceKey: string;
437
+ name: string;
438
+ args: unknown;
439
+ tables: string[];
440
+ }
441
+
227
442
  export interface OptimisticBackendOptions {
228
443
  /** Stable per-client identity for the upstream envelopes (§8.1). */
229
444
  clientID: string;
@@ -237,6 +452,22 @@ export interface OptimisticBackendOptions {
237
452
  /** Virtual-clock seam for the fold debounce timers (FOLDED-MUTATIONS-DESIGN §9). Defaults to
238
453
  * real `setTimeout`/`clearTimeout`/`Date.now`; the fold oracle injects a deterministic clock. */
239
454
  clock?: FoldClock;
455
+ /** The DECLARED confirming stream per mutation (302 §5: declared, not derived — there is no
456
+ * routing proof). A policy returning a string pins that domain verbatim: the mutation stages
457
+ * onto that room's namespaced tables and ships on its channel. Returning `undefined` (or
458
+ * configuring no policy) means `"daemon"`. The client layer builds this from the app's declared
459
+ * realtime mutators + the currently attached rooms; a misdeclaration fails SOFT (302 §5.1) —
460
+ * the write lands on the other authority's tables and the view simply stops feeling instant
461
+ * until the echo relays it. */
462
+ domainPolicy?: (name: string, args: unknown) => string | undefined;
463
+ /** A FINAL (authz/validation) mutation rejection's reason surface — the room plane's twin of the
464
+ * HTTP mutate route's `onRejected` (H-v; the H-iv-b `mutationOutcome {kind:"rejected"}` frame).
465
+ * The prediction's snap-back is NOT this callback's job: the room burns the mid and its lmid
466
+ * release drops the entry exactly as a daemon-path rejection does (processed-as-no-op) — this
467
+ * is where the REASON reaches the app, same contract as the queue's callback. Also invoked when
468
+ * a DEOPT's fresh re-invocation (the already-retired arm) throws — that mutation is dead on the
469
+ * current base with no stream left to confirm it, the closest thing to a rejection there is. */
470
+ onRejected?: (envelope: MutationEnvelope, reason: string) => void;
240
471
  }
241
472
 
242
473
  // --- dev-only introspection (DEBUG-TOOLS-BROWSER-DESIGN §2/§4.1) -----------------
@@ -268,6 +499,13 @@ export interface PendingInspect {
268
499
  args: unknown;
269
500
  /** Tables this mutator touched at its last (re)invocation — the pending-axis basis (§7.2). */
270
501
  tables: string[];
502
+ /** The pk-granular write-set captured at this entry's LAST invocation (RINDLE-REALTIME-QUERY-
503
+ * ENABLEMENT-DESIGN.md §3.2 #1), flattened from the {@link WriteSet} map for inspection — one
504
+ * entry per `(table, pk)` currently held. Pure capture; no routing consumer yet. */
505
+ writes: WriteRecord[];
506
+ /** The read-log captured at this entry's LAST *recorded* invocation (§3.2 #2). Empty for a
507
+ * folded entry — the read TRAP arms there, not recording (see {@link PendingMutation.reads}). */
508
+ reads: ReadLog;
271
509
  /** Present iff this entry is a folded (debounced) write. */
272
510
  fold?: FoldInspect;
273
511
  }
@@ -299,6 +537,15 @@ const REAL_CLOCK: FoldClock = {
299
537
  now: () => Date.now(),
300
538
  };
301
539
 
540
+ /** The (shared, frozen-by-convention) empty map {@link OptimisticBackend.roomTablesFor} answers
541
+ * for a room with no registered tables. */
542
+ const EMPTY_ROOM_TABLES: ReadonlyMap<string, string> = new Map();
543
+
544
+ /** Per-domain retention cap for the processed-outcome set (H-v) — mirrors the shell's
545
+ * `MAX_RECORDED_OUTCOMES_PER_CLIENT`: the sender caps what it can re-answer at 512 per client,
546
+ * so remembering more than 512 processed mids per domain buys nothing. */
547
+ const MAX_PROCESSED_OUTCOMES_PER_DOMAIN = 512;
548
+
302
549
  export class OptimisticBackend<S extends ColsMap> implements Backend {
303
550
  private readonly local: WasmBackend<S>;
304
551
  private readonly sync: NormalizedSync;
@@ -321,6 +568,9 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
321
568
  * its width check. */
322
569
  private readonly colCounts: ColCounts;
323
570
  private readonly colIndex: Record<string, Map<string, number>>;
571
+ /** Per-table pk column indices — held so `connectSource` can build a fresh per-source
572
+ * `NormalizedSync` with the same layout the daemon's uses. */
573
+ private readonly pkCols: PkCols;
324
574
  /** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
325
575
  * of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
326
576
  * queries arrive (`ensureSyntheticTables`). */
@@ -345,6 +595,12 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
345
595
  // only for the duration of the reconcile cycle in `onProgress`; a re-hydrate after a drop is a real
346
596
  // footprint diff (genuine change) and is NOT remapped. See {@link ChangeEvent} `catchUp`.
347
597
  private catchUpQids: Set<QueryId> | null = null;
598
+ /** Newly-hydrated qids whose reconcile ACTUALLY emitted a (catch-up-stamped) batch — recorded by the
599
+ * local-event forwarder alongside {@link catchUpQids}. After the reconcile, any newly-hydrated qid
600
+ * NOT in here folded nothing (0 rows, or its result already present via a sibling → 0 net muts, or
601
+ * the reconcile was skipped), so `onProgress` sends it an explicit empty catch-up — else its SSR
602
+ * seed would never retire (the view freezes). Non-null only for the reconcile's duration. */
603
+ private catchUpEmitted: Set<QueryId> | null = null;
348
604
  /** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the
349
605
  * local engine's `dispatch` brackets so the Store folds every affected view before notifying any
350
606
  * subscriber (cross-view-atomic notification). All this backend's data deltas originate from the
@@ -353,18 +609,115 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
353
609
  private readonly devObservers = new Set<BackendDevObserver>();
354
610
 
355
611
  private pendingMutations: PendingMutation[] = [];
356
- private nextMid = 1;
612
+ /** The next mid to deal, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1): a client
613
+ * writing through room + daemon concurrently must not alias one lmid counter. Seeded with the
614
+ * `"daemon"` stream at 1; a domain absent from the map starts at 1. In the single-domain
615
+ * configuration only `"daemon"` is ever touched, so the sequence is byte-for-byte as before. */
616
+ private nextMid = new Map<string, number>([["daemon", 1]]);
617
+ /** The client-global deal counter behind {@link PendingMutation.seq}: one sequence across ALL
618
+ * domains, bumped whenever any domain's mid is dealt. The replay order (mids are per-domain and
619
+ * incomparable across domains — see the `seq` field doc). */
620
+ private dealSeq = 0;
621
+ /** The explicit confirming-stream override (§7.1/§3) — see
622
+ * {@link OptimisticBackendOptions.domainPolicy}. `undefined` from it ⇒ H-iii derivation. */
623
+ private readonly domainPolicy: (name: string, args: unknown) => string | undefined;
624
+ /** The final-rejection reason surface ({@link OptimisticBackendOptions.onRejected}). */
625
+ private readonly rejectedHandler: (envelope: MutationEnvelope, reason: string) => void;
626
+ /** Processed `(domain, mid)` outcome frames (H-v) — the deopt handshake's idempotence guard: a
627
+ * duplicate frame (the original plus a reconnect re-send's re-answer, or two re-answers across
628
+ * two reconnects) must not double-invoke. Needed precisely because a deopt frame can arrive for
629
+ * an ALREADY-RETIRED mid (the replay gotcha) — "no matching entry" alone cannot distinguish
630
+ * "handle it fresh" from "already handled". Per-domain FIFO, capped like the shell's
631
+ * recorded-outcome map ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}); past the cap a duplicate of
632
+ * an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
633
+ * takes 512 interleaving non-applied outcomes on one domain to open it. */
634
+ private readonly outcomesProcessed = new Map<string, Set<number>>();
635
+ /** THE room-table registry (302 §2 — one source per table): per connected room `sourceKey`, the
636
+ * wire-table → engine-table map for the tables that room OWNS (its writable scope). Written by
637
+ * {@link registerRoomTables} (same breath as the engine registration); read by the gate's
638
+ * release rename/filter, the mutator staging map, the view swap ({@link processSwapIns}), and
639
+ * the client's `__realtimeInspect` bookkeeping. The record outlives a downgrade's disconnect —
640
+ * the ghost's views still read the engine tables — and drops at {@link dropGhost} (or the last
641
+ * clean release via {@link unregisterRoomTables}). */
642
+ private readonly roomTables = new Map<string, Map<string, string>>();
643
+ /** Local view qids currently REGISTERED on a room's namespaced tables (302 §4 swap-in), →
644
+ * their sourceKey. Set by {@link processSwapIns}; cleared by the swap-back ({@link dropGhost})
645
+ * and view teardown. The original AST stays in {@link asts} throughout — the swap re-registers
646
+ * only the ENGINE query. */
647
+ private readonly roomSwappedViews = new Map<QueryId, string>();
648
+ /** Room subs whose FIRST snapshot released in the current release — their views swap onto the
649
+ * room tables at the release tail ({@link processSwapIns}), strictly AFTER the reconcile folded
650
+ * the snapshot into those tables (swapping earlier would hydrate the view EMPTY, a flash). */
651
+ private readonly pendingSwapIns = new Set<RemoteSub>();
357
652
  /** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
358
653
  * (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
359
654
  private readonly folds = new Map<string, FoldRecord>();
360
655
  /** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
361
656
  private readonly clock: FoldClock;
362
- /** The high-water confirmed mutation id, folded from the lmid system query's
363
- * RELEASED ops (lmid-as-data) never from a frame. */
364
- private confirmedLmid = 0;
365
- private buffer: BufferedFrame[] = [];
366
- private nextSeq = 0;
367
- private appliedCv = 0;
657
+ /** The high-water confirmed mutation id, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
658
+ * §7.2 per-domain confirm-drop): an entry with `mid <= watermark[entry.domain]` has been
659
+ * confirmed. The `"daemon"` domain is folded from the lmid system query's RELEASED ops
660
+ * (lmid-as-data) never from a frame; a room domain will fold from its own lmid stream (later
661
+ * slice). Seeded with `"daemon"` at 0; the daemon scalar `confirmedLmid` (devtools) is
662
+ * `watermark.get("daemon")`. */
663
+ private watermark = new Map<string, number>([["daemon", 0]]);
664
+ /** The per-source coherence gates (§5.1), by source key. Seeded with the daemon gate at
665
+ * construction; a room channel attaches later (`connectSource`). Single-domain: one entry,
666
+ * and every gate-generalized path degenerates to the old single-buffer code. NOT the same
667
+ * space as {@link watermark}/{@link nextMid}: a DOMAIN can confirm with no gate connected
668
+ * (the `__testRelease` seam); a gate's `key` names the domain its lmid stream folds into. */
669
+ private readonly gates = new Map<string, SourceGate>();
670
+ /** The daemon's gate — the always-present channel (constructor-attached). The devtools
671
+ * scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
672
+ * synthetic tables are daemon-tracked by design). */
673
+ private readonly daemonGate: SourceGate;
674
+ // --- the §4 lifecycle SYSTEM-STREAM plane (Slice I-iii) --------------------------------
675
+ /** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
676
+ * and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
677
+ * fold at RELEASE time ({@link foldSystemFrames}), never entering the sync layer or the local
678
+ * engine. The spec names which system table the qid serves and the scope/doc it was minted for
679
+ * (the fold's row filter). Empty on every non-lifecycle client — every partition below is then
680
+ * a structural no-op and the release path is byte-identical to before. */
681
+ private readonly systemQids = new Map<QueryId, SystemStreamSpec>();
682
+ /** The §4.2 fence state: room doc → highest `flush_seq` delivered through the daemon plane
683
+ * (monotone max-fold; a remove never regresses it). Slice I-v's ghost-drop consumer — I-iii
684
+ * only maintains + exposes it (`__inspectDomains().lifecycle`). */
685
+ private readonly roomWatermarks = new Map<string, number>();
686
+ /** The §4.1 occupancy state: scope → (client_id → expires_at) from the doorbell stream. Slice
687
+ * I-iv's doorbell consumer (the 1→2 re-lease reaction) — I-iii only maintains + exposes it.
688
+ * A snapshot REPLACES the scope's map (authoritative re-hydrate); a batch folds add/edit/remove
689
+ * incrementally (the age-out sweep's deletes arrive as removes). */
690
+ private readonly scopeSessions = new Map<string, Map<string, number>>();
691
+ /** The I-iv doorbell event sink ({@link onScopeSessions}) — fired once per scope a release's
692
+ * scope-session fold touched, AFTER the whole release applied. Default no-op: a client that
693
+ * never registers (no lifecycle plane) pays nothing. */
694
+ private scopeSessionsHandler: (event: ScopeSessionsEvent) => void = () => {};
695
+ /** Deferred old-channel row GC for in-flight upgrade retargets ({@link retargetRemoteQuery}):
696
+ * sub sourceQid → the channel it left. The rows the OLD gate's sync holds for the qid stay
697
+ * visible (merge: daemon tier) until the sub's first snapshot RELEASES on its new room channel
698
+ * ({@link flushRetargetGc}) — dropping them at retarget time would emit net removes ahead of
699
+ * the room's re-adds, the flicker the two-phase cutover exists to avoid. Doubles as the
700
+ * wrong-channel GRACE window in {@link onFrame}: a frame already in flight from the old
701
+ * channel when the sub moved is stale, not a wiring bug. Empty on every non-upgrade client —
702
+ * every consultation below is then a structural no-op. */
703
+ private readonly pendingRetargetGc = new Map<QueryId, string>();
704
+ /** The §4.2 GHOSTS (Slice I-v): demoted room sources awaiting their watermark fence, by
705
+ * sourceKey. Written only by {@link demoteRoomSource}; evaluated after every release
706
+ * ({@link evaluateGhosts}) and dropped by {@link dropGhost} once the fence clears with no
707
+ * sent room-domain pending left. Empty on every non-downgrade client — the per-release
708
+ * evaluation is then a structural no-op. */
709
+ private readonly ghosts = new Map<string, RoomGhost>();
710
+ /** The I-v stuck-downgrade surface ({@link onDowngradeStuck}) — fired AT MOST ONCE per ghost
711
+ * when its fence is satisfied but sent room-domain mids remain unresolved (§7.5: they retire
712
+ * only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
713
+ * Default no-op. */
714
+ private downgradeStuckHandler: (event: DowngradeStuckEvent) => void = () => {};
715
+ /** The 302 §6.1 context-coverage surface ({@link onRoomContextJoin}) — fired at most once per
716
+ * view ({@link contextJoinWarned}), at swap-in, when its AST references tables the room does
717
+ * not own. Default no-op. */
718
+ private roomContextJoinHandler: (event: RoomContextJoinEvent) => void = () => {};
719
+ /** Views the context-coverage event already fired for (once per view; cleared on teardown). */
720
+ private readonly contextJoinWarned = new Set<QueryId>();
368
721
 
369
722
  private readonly asts = new Map<QueryId, Ast>();
370
723
  /** Per query: the base tables its result can draw from (from the AST tree). */
@@ -396,16 +749,20 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
396
749
  this.local = new WasmBackend(schema);
397
750
  this.local.onEvent((qid, ev) => {
398
751
  // Stamp a newly-hydrating query's reconcile batch as a catch-up (initial-hydration) delivery,
399
- // so the Store phases it as a `snapshot` rather than narrating the whole first result set.
400
- const stamped = ev.type === "batch" && this.catchUpQids?.has(qid) ? { ...ev, catchUp: true } : ev;
401
- this.handler(qid, stamped);
752
+ // so the Store phases it as a `snapshot` rather than narrating the whole first result set. Record
753
+ // that we emitted a hydration batch for this qid, so `onProgress` knows which newly-hydrated qids
754
+ // still need an explicit empty catch-up (they folded nothing — see {@link catchUpEmitted}).
755
+ const stamp = ev.type === "batch" && this.catchUpQids?.has(qid) === true;
756
+ if (stamp) this.catchUpEmitted?.add(qid);
757
+ this.handler(qid, stamp ? { ...ev, catchUp: true } : ev);
402
758
  });
403
759
  // Forward the local engine's commit brackets up to the Store (cross-view-atomic notification):
404
760
  // every data delta this backend emits comes from `this.local`, so its commit boundaries are ours.
405
761
  this.local.onCommitBoundary((phase) => this.boundaryHandler(phase));
406
762
  this.colCounts = colCountsFromSchema(schema);
407
763
  this.colIndex = colIndexFromSchema(schema);
408
- this.sync = new NormalizedSync(pkColsFromSchema(schema), this.colCounts);
764
+ this.pkCols = pkColsFromSchema(schema);
765
+ this.sync = new NormalizedSync(this.pkCols, this.colCounts);
409
766
  this.specs = tableSpecsFromSchema(schema);
410
767
  this.localTables = localTableNames(schema);
411
768
  this.source = source;
@@ -414,23 +771,119 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
414
771
  this.user = opts.user ?? (() => "");
415
772
  this.bufferCap = opts.bufferCap ?? 1024;
416
773
  this.clock = opts.clock ?? REAL_CLOCK;
417
- // Validate each server hello against our OWN typed schema reject a schema skew
418
- // (CRIT#4). The reserved lmid table is part of the expected set so the system
419
- // query's hello passes. Synthetic agg tables join the set as queries register them.
420
- this.clientTablesBase = [...normalizedTableSchemas(schema), CLIENT_MUTATIONS_SCHEMA];
421
- this.source.expectClientSchema?.(this.clientTablesBase);
422
- this.source.onNormalized((qid, ev) => this.onNormalized(qid, ev));
423
- this.source.onProgress((frame) => this.onProgress(frame));
424
- this.source.onRestart?.(() => this.resetForRestart());
425
- // The lmid system query (lmid-as-data): our confirmations arrive on this stream,
426
- // cv-tagged, released by the same cvMin as the data they belong to. The server
427
- // derives the identity from the connection; args are advisory.
428
- this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
774
+ // No policy configured every route DERIVES (H-iii §3). With no room gate connected the
775
+ // derivation short-circuits to "daemon", so a single-domain app is byte-for-byte as before.
776
+ this.domainPolicy = opts.domainPolicy ?? (() => undefined);
777
+ this.rejectedHandler = opts.onRejected ?? (() => {});
778
+ // The reserved lmid table + (I-iii) the four lifecycle system tables join the expected set so
779
+ // a system subscription's hello passes CRIT#4 validation. Extra CLIENT-side entries are inert
780
+ // for every other server hello (validation only checks tables a server advertises), so a
781
+ // client that never receives a lifecycle block is byte-identical.
782
+ this.clientTablesBase = [...normalizedTableSchemas(schema), CLIENT_MUTATIONS_SCHEMA, ...LIFECYCLE_TABLE_SCHEMAS];
783
+ // The daemon is the always-present channel: its gate is attached at construction, and its
784
+ // per-source refcount space IS `this.sync` (the agg overlay reads it directly). A room
785
+ // channel attaches through the same seam later (§5.1; Slice G).
786
+ this.daemonGate = this.attachGate("daemon", source, this.sync);
787
+ }
788
+
789
+ /** Wire one authority channel into its own coherence gate (§5.1): every frame the channel
790
+ * delivers buffers on THIS gate's cv timeline, its progress frames release THIS buffer, its
791
+ * restart resets THIS gate alone, and its reserved lmid stream folds into `watermark[key]`.
792
+ * Validates each server hello against our OWN typed schema → reject a schema skew (CRIT#4);
793
+ * the reserved lmid table is part of the expected set so the system query's hello passes, and
794
+ * synthetic agg tables join the set as queries register them. */
795
+ private attachGate(key: string, source: OptimisticSource, sync: NormalizedSync): SourceGate {
796
+ const gate: SourceGate = { key, source, sync, buffer: [], nextSeq: 0, appliedCv: 0 };
797
+ this.gates.set(key, gate);
798
+ source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
799
+ source.onNormalized((qid, ev) => this.onFrame(gate, qid, ev));
800
+ source.onProgress((frame) => this.onGateProgress(gate, frame));
801
+ source.onRestart?.(() => this.resetGate(gate));
802
+ // The deopt handshake's client half (H-v §3.3): the channel's `mutationOutcome` frames arrive
803
+ // as `(domain = gate.key, frame)`. OUT-OF-BAND — the source dispatches on arrival and this
804
+ // handler runs immediately, NEVER behind the gate's cv buffer: a deopt must migrate its entry
805
+ // BEFORE the buffered lmid release that would otherwise retire it as a success (and the §7.3
806
+ // hold-back trigger, keyed on `p.domain`, would park its staged writes the wrong way).
807
+ source.onMutationOutcome?.((frame) => this.handleMutationOutcome(gate.key, frame));
808
+ // §7.5 rule 3 (H-v): a re-established session re-sends this DOMAIN's unconfirmed pending
809
+ // envelopes with their ORIGINAL mids — the authority's own ledger dedups (an applied mid is
810
+ // silent; a non-applied one is re-answered from the recorded-outcome map into the handler
811
+ // above). This is the deopt crash-window closer: a frame lost with its socket is re-earned.
812
+ source.onResync?.(() => this.resendPending(gate.key));
813
+ // The lmid system query (lmid-as-data): confirmations arrive on this channel's stream,
814
+ // cv-tagged, released by the same cvMin as the data they belong to. The server derives
815
+ // the identity from the connection; args are advisory. Qid 0 is reserved PER CHANNEL —
816
+ // it never collides with Store-dealt qids and never enters the sync layer.
817
+ source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
818
+ return gate;
819
+ }
820
+
821
+ /** Attach a SECOND authority channel (§5.1) — the seam Slice G's room upgrade calls with the
822
+ * ws-backed room feed. Rooms speak the daemon protocol verbatim (§2.4: the client cannot tell
823
+ * a room from the daemon), so the argument is a full {@link OptimisticSource} — exactly what
824
+ * `@rindle/remote` builds from `{roomUrl, leaseToken}`. The channel buffers/releases on its
825
+ * own cv timeline (an independent §5.1 gate: coherent within, eventual across) and its
826
+ * reserved lmid stream folds into `watermark[sourceKey]` — so `sourceKey` must equal the
827
+ * `domainPolicy` name for the mutations this authority confirms. The converse is NOT required:
828
+ * a domain may exist with no connected gate (`__testRelease` drives confirms gate-less); the
829
+ * live production path stays daemon-only until G calls this. */
830
+ connectSource(sourceKey: string, source: OptimisticSource): void {
831
+ if (this.gates.has(sourceKey)) {
832
+ throw new Error(`optimistic backend: source ${sourceKey} is already connected`);
833
+ }
834
+ const gate = this.attachGate(sourceKey, source, new NormalizedSync(this.pkCols, this.colCounts));
835
+ // A re-upgrade of a doc whose tables are still registered (a ghost that never dropped, or a
836
+ // quick down/up bounce) adopts the surviving record as this incarnation's rename map.
837
+ const tables = this.roomTables.get(sourceKey);
838
+ if (tables !== undefined) gate.tableMap = tables;
839
+ // …and CANCELS the pending swap-back: the room is the authority again, its views stay swapped,
840
+ // and a ghost left armed would fire against this LIVE gate when the old fence clears —
841
+ // un-swapping the views and unregistering the namespaced tables the gate's tableMap still
842
+ // renames deltas into (the next release would then throw from serverBatchBegin and poison the
843
+ // rebase state). A future downgrade arms a fresh ghost with its own fence.
844
+ this.ghosts.delete(sourceKey);
845
+ }
846
+
847
+ /** Register the tables room `sourceKey` OWNS (its writable scope — 302 §2): each wire table
848
+ * gets its own namespaced ENGINE table (`{@link roomEngineTable}`), an ordinary tracked table
849
+ * whose sole authority is the room channel. From here on the channel's released deltas rename
850
+ * into these tables (wire tables outside the map are DROPPED — context stays daemon-owned,
851
+ * 302 §6), room-domain mutators stage onto them, and a room-homed view swaps onto them once
852
+ * the room sub hydrates ({@link processSwapIns}). Idempotent per (sourceKey, table); a wire
853
+ * table unknown to the schema is skipped (nothing to hold rows for). */
854
+ registerRoomTables(sourceKey: string, tables: readonly string[]): void {
855
+ if (sourceKey === "daemon") {
856
+ throw new Error("optimistic backend: the daemon is not a room — no namespaced tables");
857
+ }
858
+ let map = this.roomTables.get(sourceKey);
859
+ if (!map) this.roomTables.set(sourceKey, (map = new Map()));
860
+ for (const table of tables) {
861
+ if (map.has(table)) continue;
862
+ const spec = this.specs[table];
863
+ if (spec === undefined || this.localTables.has(table)) continue;
864
+ const engineTable = roomEngineTable(table, sourceKey);
865
+ this.local.registerTable(engineTable, { columns: spec.columns, primaryKey: spec.primaryKey });
866
+ map.set(table, engineTable);
867
+ }
868
+ const gate = this.gates.get(sourceKey);
869
+ if (gate !== undefined) gate.tableMap = map;
870
+ }
871
+
872
+ /** The wire-table → engine-table map for room `sourceKey`'s owned tables (empty when none) —
873
+ * the client's idempotence check and `__realtimeInspect` read THIS record (one source of
874
+ * truth; the client keeps no shadow copy). */
875
+ roomTablesFor(sourceKey: string): ReadonlyMap<string, string> {
876
+ return this.roomTables.get(sourceKey) ?? EMPTY_ROOM_TABLES;
429
877
  }
430
878
 
431
879
  // --- the Backend seam ---------------------------------------------------------
432
880
 
433
- registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery): void {
881
+ /** `channel` (G-iii registration-time routing) names the authority channel the remote sub
882
+ * registers on — a `connectSource`d gate key; default `"daemon"` (every existing caller is
883
+ * byte-identical). Slice G-v threads the lease's `realtime.sourceKey` here. Validated FIRST
884
+ * (like the E3 check below): a bad channel must throw before any per-query state is recorded. */
885
+ registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery, channel?: string): void {
886
+ if (remote) this.requireGate(channel ?? "daemon");
434
887
  // queryTables is derived from the ORIGINAL ast — its `count(comments)` subquery names
435
888
  // `comment`, so an optimistic comment mutation flips this query to `unknown` (§6). The
436
889
  // local engine, by contrast, runs the REWRITTEN ast (reads the synthetic `__agg_*`).
@@ -461,11 +914,11 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
461
914
  // child is left a native reduce (L1) — `rewriteAggregates`/`ensureSyntheticTables` skip it.
462
915
  this.ensureSyntheticTables(qid, ast);
463
916
  // Local first (synchronous empty view), then the server stream hydrates it.
464
- this.local.registerQuery(qid, rewriteAggregates(ast, (t) => this.localTables.has(t)));
917
+ this.local.registerQuery(qid, this.plainEngineAst(ast));
465
918
  if (remote) {
466
919
  // A remote query is `unknown` until its first server snapshot lands (hydration); retainRemote
467
920
  // attaches it to the sub and sets the lifecycle against the sub's hydration state.
468
- this.retainRemote(qid, remote);
921
+ this.retainRemote(qid, remote, qid, channel);
469
922
  } else {
470
923
  // No server stream (a purely local AST view — or the local half of a split retain whose
471
924
  // remote attaches separately via `retainRemoteQuery`): local data is synchronous, so it is
@@ -531,10 +984,21 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
531
984
  }
532
985
 
533
986
  unregisterQuery(qid: QueryId): void {
987
+ this.roomSwappedViews.delete(qid); // a swapped view's teardown forgets its room backing
988
+ this.contextJoinWarned.delete(qid); // …and its once-per-view coverage-warn latch
534
989
  const remoteQid = this.releaseRemote(qid);
535
- if (remoteQid !== undefined) this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
536
- // GC: rows this remote footprint SOLELY referenced fall to refcount 0 net removes.
537
- const gc = remoteQid === undefined ? [] : this.sync.dropQuery(remoteQid);
990
+ // GC: rows this remote footprint SOLELY referenced fall to refcount 0 → net removes. A qid
991
+ // lives on ONE channel, so at most one gate's dropQuery is non-empty (dropQuery of an
992
+ // unknown qid returns []) but sweep every gate so this needs no ownership lookup.
993
+ const gcs: [string, Mutation[]][] = [];
994
+ if (remoteQid !== undefined) {
995
+ this.pendingRetargetGc.delete(remoteQid); // the sweep below covers a mid-retarget teardown
996
+ for (const gate of this.gates.values()) {
997
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
998
+ const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
999
+ if (gc.length) gcs.push([gate.key, gc]);
1000
+ }
1001
+ }
538
1002
  // Tear down the local pipeline+view first so the reconcile cycle below skips it.
539
1003
  this.local.unregisterQuery(qid);
540
1004
  // The GC removals must leave BOTH head AND the engine's `sync` baseline. A plain
@@ -542,8 +1006,9 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
542
1006
  // optimistic REMOVE: the next release's rewind diffs head against sync+D and RESURRECTS
543
1007
  // them, GC never frees anything, and a later query is served the stale/deleted row
544
1008
  // forever (CRIT#2). Deliver them as a coherent SERVER delta instead — the same
545
- // sync-moving boundary `onProgress` uses — so head and sync both drop the rows.
546
- if (gc.length) this.runReconcileCycle(gc);
1009
+ // sync-moving boundary the release gate uses — so head and sync both drop the rows,
1010
+ // against the SOURCE whose baseline held them.
1011
+ for (const [key, gc] of gcs) this.runReconcileCycle(key, gc);
547
1012
  // The local pipeline is gone (no live conn) and the remote footprint's `__agg` rows were
548
1013
  // GC'd above, so any synthetic table this was the last reader of can now be freed (§4).
549
1014
  this.releaseSyntheticTables(qid);
@@ -555,9 +1020,14 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
555
1020
  // Store counter — re-materialize gets a fresh id, never this one again), so drop it on teardown.
556
1021
  }
557
1022
 
558
- retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast): void {
1023
+ /** `channel` as in {@link registerQuery} (G-iii): the gate the remote sub registers on; default
1024
+ * `"daemon"`. This is the split-retain seam G-v's resolve-then-register drives — resolve the
1025
+ * lease, learn `realtime.sourceKey`, `connectSource` it, then retain the query on that channel.
1026
+ * Validated FIRST so a bad channel throws before any synthetic-table refcount moves. */
1027
+ retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast, channel?: string): void {
1028
+ this.requireGate(channel ?? "daemon");
559
1029
  if (ast) this.ensureSyntheticTables(qid, ast);
560
- this.retainRemote(qid, remote, localQueryId);
1030
+ this.retainRemote(qid, remote, localQueryId, channel);
561
1031
  }
562
1032
 
563
1033
  releaseRemoteQuery(qid: QueryId): void {
@@ -568,9 +1038,312 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
568
1038
  this.hydrated.delete(qid);
569
1039
  }
570
1040
  if (remoteQid === undefined) return;
571
- this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
572
- const gc = this.sync.dropQuery(remoteQid);
573
- if (gc.length) this.runReconcileCycle(gc);
1041
+ // A mid-retarget release: the every-gate sweep below IS the deferred old-channel GC
1042
+ // (dropQuery hits the old gate's sync too), so retire the pending record — and its
1043
+ // wrong-channel grace — with it.
1044
+ this.pendingRetargetGc.delete(remoteQid);
1045
+ // Per-gate sweep, like `unregisterQuery`: at most one gate owned this qid's frames/rows.
1046
+ for (const gate of this.gates.values()) {
1047
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
1048
+ const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
1049
+ if (gc.length) this.runReconcileCycle(gate.key, gc);
1050
+ }
1051
+ }
1052
+
1053
+ /** The Slice I-iv upgrade retarget (§4.1 "Retarget" / the doorbell reaction): move a LIVE
1054
+ * (name, args) sub — every retain of it and every local view it feeds, wholesale — from the
1055
+ * channel it lives on onto `sourceKey`'s (already-`connectSource`d, already-promoted) room
1056
+ * channel, WITHOUT the view ever dropping its rows. Returns the sub's wire `sourceQid` (the
1057
+ * identity the client's renewal loop re-subscribes with).
1058
+ *
1059
+ * Why a dedicated primitive: the one-channel-per-(name,args) invariant ({@link retainRemote}'s
1060
+ * loud throw) is correct — a sub's frames must never split across two cv timelines — so the
1061
+ * upgrade cannot simply retain a second sub on the room and release the daemon one; and the
1062
+ * naive release-then-retain order GCs the daemon sync's rows synchronously (net removes emit,
1063
+ * the view flashes empty) a full ws round trip before the room's seq-0 snapshot refills it.
1064
+ * The cutover is therefore TWO-PHASE around the room's first release:
1065
+ *
1066
+ * 1. NOW (here): unsubscribe the old channel's wire sub, sweep its still-buffered frames for
1067
+ * this qid (their cv timeline continues without the sub — the hello-supersession
1068
+ * precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
1069
+ * cutover point), and register on the room source (its resolver presents the handed
1070
+ * roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
1071
+ * plain tables populated through the window — the view still reads them until the swap.
1072
+ * 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT: the reconcile folds the snapshot into the room's
1073
+ * namespaced tables, the release tail SWAPS every local view onto them (302 §4.1,
1074
+ * {@link processSwapIns} — the accepted-flash boundary), and {@link flushRetargetGc}'s
1075
+ * deferred `dropQuery`+reconcile on the OLD gate then GCs the plain-table rows the sub
1076
+ * alone referenced — invisible to the swapped views.
1077
+ *
1078
+ * Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
1079
+ * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey)). Validates before
1080
+ * mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
1081
+ retargetRemoteQuery(remote: RemoteQuery, sourceKey: string): QueryId {
1082
+ const newGate = this.requireGate(sourceKey); // throw loudly BEFORE any sub state moves
1083
+ const key = remoteKey(remote);
1084
+ const sub = this.remoteSubs.get(key);
1085
+ if (!sub) {
1086
+ throw new Error(
1087
+ `optimistic backend: no live sub for query "${remote.name}" — nothing to retarget`,
1088
+ );
1089
+ }
1090
+ if (sub.channel === sourceKey) return sub.sourceQid; // already there — idempotent
1091
+ const oldGate = this.gates.get(sub.channel) ?? this.daemonGate;
1092
+ oldGate.source.unregisterQuery(sub.sourceQid);
1093
+ oldGate.buffer = oldGate.buffer.filter((f) => f.qid !== sub.sourceQid);
1094
+ this.pendingRetargetGc.set(sub.sourceQid, sub.channel);
1095
+ sub.channel = sourceKey;
1096
+ sub.hydrated = false;
1097
+ newGate.source.registerQuery(sub.sourceQid, remote);
1098
+ return sub.sourceQid;
1099
+ }
1100
+
1101
+ /** Phase 2 of {@link retargetRemoteQuery}, run at the end of every gate release: once a
1102
+ * retargeted sub's first snapshot has RELEASED on its new channel (`sub.hydrated` re-armed at
1103
+ * retarget, re-set by {@link markSubHydrated} inside this very release), drop the qid's rows
1104
+ * from the OLD gate's sync and reconcile them out — after the room's rows are already applied,
1105
+ * so the winner flip is value-equal (net-zero; see the phase table above). A sub torn down
1106
+ * mid-window was already swept by `releaseRemoteQuery`/`unregisterQuery` (which delete the
1107
+ * record); a vanished record here is pruned defensively. */
1108
+ private flushRetargetGc(gate: SourceGate): void {
1109
+ if (this.pendingRetargetGc.size === 0) return; // every non-upgrade release: structural no-op
1110
+ for (const [sourceQid, oldGateKey] of this.pendingRetargetGc) {
1111
+ const key = this.sourceToRemote.get(sourceQid);
1112
+ const sub = key !== undefined ? this.remoteSubs.get(key) : undefined;
1113
+ if (!sub) {
1114
+ this.pendingRetargetGc.delete(sourceQid);
1115
+ continue;
1116
+ }
1117
+ if (sub.channel !== gate.key || !sub.hydrated) continue; // not this gate / not yet cut over
1118
+ this.pendingRetargetGc.delete(sourceQid);
1119
+ const oldGate = this.gates.get(oldGateKey);
1120
+ if (!oldGate) continue;
1121
+ const gc = mapGateDeltas(oldGate, oldGate.sync.dropQuery(sourceQid));
1122
+ if (gc.length) this.runReconcileCycle(oldGateKey, gc);
1123
+ }
1124
+ }
1125
+
1126
+ // --- the §4.2 downgrade: demote → ghost → fence → drop (Slice I-v) ----------------------
1127
+
1128
+ /** The I-v downgrade orchestration primitive (§4.2/§7.4, re-expressed by 302 §4.2 as the
1129
+ * SWAP-BACK GATE): retire room `sourceKey` behind the watermark fence. The caller has ALREADY
1130
+ * retargeted every live sub off the channel ({@link retargetRemoteQuery} room→daemon —
1131
+ * validated loudly below) and holds the fence from the api-server's downgrade response
1132
+ * (`finalFlushSeq` = the room's last COMMITTED flush seq; `doc` keys the §4.2 watermark fold,
1133
+ * {@link roomWatermarks}). Steps, in order:
1134
+ *
1135
+ * 1. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
1136
+ * dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
1137
+ * an assigned mid pins its domain; a later re-upgrade of the same doc continues the
1138
+ * sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
1139
+ * into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
1140
+ * load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
1141
+ * gone ⇒ record deleted, nothing dropped). The room's namespaced tables — and the views
1142
+ * swapped onto them — deliberately stay: frozen at the room's last state, they keep the
1143
+ * document visible while the falling-back follower may still lack the final flush.
1144
+ * Swapping back earlier would show its pre-flush images — the regression §4.2 prevents.
1145
+ * 2. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
1146
+ * immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
1147
+ * drops on the spot, the single-daemon first-frame case.
1148
+ *
1149
+ * In-flight discipline (§7.5): entries with `mid !== null` on `sourceKey` stay PINNED (rule
1150
+ * 2 — never re-route a sent mutation); their resolution arrives via the daemon-carried
1151
+ * ledger+outcome folds (I-iii) and blocks the drop until then. Idempotent per sourceKey (a
1152
+ * second labeled query sharing the room demotes into the existing ghost). */
1153
+ demoteRoomSource(sourceKey: string, doc: string, finalFlushSeq: number): void {
1154
+ if (sourceKey === "daemon") {
1155
+ throw new Error("optimistic backend: the daemon source cannot be demoted");
1156
+ }
1157
+ // Validate FIRST (nothing mutated yet): a live sub still on the channel would silently
1158
+ // starve once the gate detaches — the caller must retarget every sub off the room first.
1159
+ for (const sub of this.remoteSubs.values()) {
1160
+ if (sub.channel === sourceKey) {
1161
+ throw new Error(
1162
+ `optimistic backend: cannot demote ${JSON.stringify(sourceKey)} — query "${sub.remote.name}" is still retained on it (retarget it to the daemon first)`,
1163
+ );
1164
+ }
1165
+ }
1166
+ // Idempotent per sourceKey (co-tenant queries sharing the room demote into the existing
1167
+ // ghost) — but NEVER a bare early-return: each demote carries its own fence, so keep the
1168
+ // NEWEST flush (monotone max — swapping back on an older fence would show pre-flush images),
1169
+ // and disconnect defensively in case a gate re-attached since the ghost was armed (a
1170
+ // down→up→down bounce; {@link connectSource} cancels the ghost on re-upgrade, so this arm
1171
+ // normally finds no gate — but a stale gate left connected would let the next daemon release
1172
+ // GC the room slice out from under the still-swapped views, the §4.2 regression).
1173
+ const existing = this.ghosts.get(sourceKey);
1174
+ if (existing) {
1175
+ this.disconnectSource(sourceKey);
1176
+ existing.finalFlushSeq = Math.max(existing.finalFlushSeq, finalFlushSeq);
1177
+ this.evaluateGhosts();
1178
+ return;
1179
+ }
1180
+ this.disconnectSource(sourceKey); // (1) the channel
1181
+ this.ghosts.set(sourceKey, { doc, finalFlushSeq, stuckReported: false }); // (2)
1182
+ this.evaluateGhosts();
1183
+ }
1184
+
1185
+ /** Detach one connected room channel (Slice I-v step 3): the source's handlers are replaced
1186
+ * with no-ops (the {@link OptimisticSource} handler seam is single-registration, so this IS
1187
+ * the detach — a late frame from a dying socket can no longer touch any bookkeeping), its
1188
+ * reserved lmid sub is unregistered, and the gate — buffer, per-source sync, cv watermark —
1189
+ * is dropped from {@link gates}. The DOMAIN state deliberately survives forever:
1190
+ * `nextMid[sourceKey]`, `watermark[sourceKey]`, and the processed-outcome set are untouched
1191
+ * (§7.1 — an assigned mid pins its domain; a re-upgrade must continue, never restart, the mid
1192
+ * sequence; {@link connectSource} then attaches a fresh gate whose lmid snapshot max-folds
1193
+ * into the surviving watermark via {@link foldConfirm}). Closing the underlying transport is
1194
+ * the caller's job. Idempotent (a missing gate is a no-op). */
1195
+ disconnectSource(sourceKey: string): void {
1196
+ if (sourceKey === "daemon") {
1197
+ throw new Error("optimistic backend: the daemon source cannot be disconnected");
1198
+ }
1199
+ const gate = this.gates.get(sourceKey);
1200
+ if (!gate) return;
1201
+ this.gates.delete(sourceKey);
1202
+ gate.source.onNormalized(() => {});
1203
+ gate.source.onProgress(() => {});
1204
+ gate.source.onRestart?.(() => {});
1205
+ gate.source.onMutationOutcome?.(() => {});
1206
+ gate.source.onResync?.(() => {});
1207
+ gate.source.unregisterQuery(LMID_QID);
1208
+ }
1209
+
1210
+ /** Register the I-v stuck-downgrade sink — see {@link DowngradeStuckEvent}. One handler (a
1211
+ * later registration replaces it, the {@link onScopeSessions} convention); client.ts maps it
1212
+ * onto the loud anomaly surface. */
1213
+ onDowngradeStuck(handler: (event: DowngradeStuckEvent) => void): void {
1214
+ this.downgradeStuckHandler = handler;
1215
+ }
1216
+
1217
+ /** Register the 302 §6.1 context-coverage sink — see {@link RoomContextJoinEvent}. One handler
1218
+ * (a later registration replaces it, the {@link onScopeSessions} convention); client.ts maps
1219
+ * it onto the loud anomaly surface. */
1220
+ onRoomContextJoin(handler: (event: RoomContextJoinEvent) => void): void {
1221
+ this.roomContextJoinHandler = handler;
1222
+ }
1223
+
1224
+ /** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
1225
+ * the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
1226
+ * once at demote time. For each ghost: the fence must be satisfied
1227
+ * (`roomWatermarks[doc] ≥ finalFlushSeq`; 0 is trivially satisfied) AND no SENT room-domain
1228
+ * pending may remain (§7.5 — such entries resolve only through the daemon-carried
1229
+ * outcome/ledger folds; an entry that never reached the room is undecidable, so the ghost
1230
+ * HOLDS and the stuck event fires exactly once, naming the mids). Both satisfied ⇒
1231
+ * {@link dropGhost}. */
1232
+ private evaluateGhosts(): void {
1233
+ if (this.ghosts.size === 0) return; // every non-downgrade release: structural no-op
1234
+ for (const [sourceKey, ghost] of [...this.ghosts]) {
1235
+ // A LIVE gate means the doc re-upgraded — dropping now would dismantle the live room
1236
+ // (un-swap its views, unregister the tables its tableMap renames into). connectSource
1237
+ // cancels the ghost on re-upgrade, so this guard is purely defensive; hold, never drop.
1238
+ if (this.gates.has(sourceKey)) continue;
1239
+ if ((this.roomWatermarks.get(ghost.doc) ?? 0) < ghost.finalFlushSeq) continue; // fence holds
1240
+ const stuck = this.pendingMutations.filter((p) => p.domain === sourceKey && p.mid !== null);
1241
+ if (stuck.length > 0) {
1242
+ if (!ghost.stuckReported) {
1243
+ ghost.stuckReported = true;
1244
+ this.downgradeStuckHandler({ sourceKey, doc: ghost.doc, mids: stuck.map((p) => p.mid as number) });
1245
+ }
1246
+ continue; // hold — never a timeout-retire (§7.5 rule 2)
1247
+ }
1248
+ this.dropGhost(sourceKey);
1249
+ }
1250
+ }
1251
+
1252
+ /** Drop one cleared ghost — the 302 §4.2 SWAP-BACK: under the fence the daemon tables are
1253
+ * value-equal-or-ahead of the room's final state, so (1) every view swapped onto the room's
1254
+ * namespaced tables re-registers on its ORIGINAL (daemon-table) AST — visually a no-op, the
1255
+ * Store folds the re-hello as an in-place reset; (2) the namespaced tables unregister (no
1256
+ * reader is left after the swap); (3) ONE daemon reconcile re-invokes the pending set so any
1257
+ * entry whose writes had staged onto the now-gone room tables re-stages onto the daemon tables
1258
+ * (its domain policy stopped naming the dead room when the client dropped it). The whole drop
1259
+ * runs under one commit boundary so the swap and the re-staged predictions notify as ONE step.
1260
+ * After this, a FUTURE upgrade of the same doc registers again from scratch. */
1261
+ private dropGhost(sourceKey: string): void {
1262
+ this.ghosts.delete(sourceKey);
1263
+ this.inOneCommit(() => {
1264
+ for (const [qid, key] of [...this.roomSwappedViews]) {
1265
+ if (key !== sourceKey) continue;
1266
+ this.roomSwappedViews.delete(qid);
1267
+ const ast = this.asts.get(qid);
1268
+ if (ast === undefined) continue;
1269
+ this.local.unregisterQuery(qid);
1270
+ this.local.registerQuery(qid, this.plainEngineAst(ast));
1271
+ }
1272
+ this.unregisterRoomTables(sourceKey);
1273
+ // One daemon reconcile re-stages the pending set onto the surviving tables. Run whenever
1274
+ // any pending exists: unregistering the room tables took their staged copies with the tree.
1275
+ if (this.pendingMutations.length > 0) this.runReconcileCycle("daemon", []);
1276
+ });
1277
+ this.refreshPending(); // the reconcile may have dropped a throwing re-invocation
1278
+ }
1279
+
1280
+ /** Unregister room `sourceKey`'s namespaced engine tables and drop the {@link roomTables}
1281
+ * record. Callers must have no view registered on them (the engine refuses otherwise —
1282
+ * loud by design). No-op for an unknown sourceKey. */
1283
+ unregisterRoomTables(sourceKey: string): void {
1284
+ const map = this.roomTables.get(sourceKey);
1285
+ if (!map) return;
1286
+ this.roomTables.delete(sourceKey);
1287
+ for (const engineTable of map.values()) this.local.unregisterTable(engineTable);
1288
+ }
1289
+
1290
+ // --- the §4 lifecycle SYSTEM-STREAM retains (Slice I-iii) ------------------------------
1291
+
1292
+ /** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
1293
+ * I-iii): a wire sub with NO store view and NO user-visible table. Registered through the same
1294
+ * {@link RemoteSub} bookkeeping as any remote retain — so qid→channel ownership, the overflow
1295
+ * re-subscribe, and refcounted release all work unchanged — but with an EMPTY `localQids` set
1296
+ * (no hydration/resultType coupling) and a {@link systemQids} record telling the release path
1297
+ * which system table this qid's frames carry (`spec.table`) and which scope/doc it was minted
1298
+ * for (the fold's row filter). Its frames then buffer on the channel's gate exactly like
1299
+ * {@link LMID_QID}'s and fold at RELEASE time in {@link foldSystemFrames} — riding the SAME
1300
+ * buffered cv path as the data they co-committed with (fence coherence: an out-of-band
1301
+ * shortcut would break I-ii's co-commit ordering guarantee).
1302
+ *
1303
+ * `channel` defaults to `"daemon"` — the system tables live in the DAEMON store (that is the
1304
+ * point: outcome/ledger/watermark rows must be readable with no room socket alive, §7.1
1305
+ * "load-bearing for §7.5"). Idempotence per (table, scope/doc) is the CALLER's job (client.ts
1306
+ * keys its retains on exactly that); a duplicate retain of the SAME remote identity refcounts
1307
+ * like any sub. */
1308
+ retainSystemQuery(retainQid: QueryId, remote: RemoteQuery, spec: SystemStreamSpec, channel = "daemon"): void {
1309
+ const gate = this.requireGate(channel); // throw loudly BEFORE any sub state moves
1310
+ const key = remoteKey(remote);
1311
+ let sub = this.remoteSubs.get(key);
1312
+ if (sub) {
1313
+ if (sub.channel !== channel) {
1314
+ throw new Error(
1315
+ `optimistic backend: system query "${remote.name}" is already retained on channel ${JSON.stringify(sub.channel)} — cannot retain it on ${JSON.stringify(channel)}`,
1316
+ );
1317
+ }
1318
+ sub.refCount++;
1319
+ this.localToRemote.set(retainQid, key);
1320
+ this.remoteRetainToLocal.set(retainQid, undefined);
1321
+ return;
1322
+ }
1323
+ // A fresh sub: deliberately NOT `retainRemote` — its `localQueryId` default would couple this
1324
+ // retain's qid to the view-hydration machinery (`hydrated`/`resultType`), and a system stream
1325
+ // has no view to hydrate.
1326
+ sub = { sourceQid: retainQid, remote, refCount: 1, localQids: new Map(), hydrated: false, channel };
1327
+ this.remoteSubs.set(key, sub);
1328
+ this.sourceToRemote.set(retainQid, key);
1329
+ this.localToRemote.set(retainQid, key);
1330
+ this.remoteRetainToLocal.set(retainQid, undefined);
1331
+ this.systemQids.set(retainQid, { ...spec });
1332
+ gate.source.registerQuery(retainQid, remote);
1333
+ }
1334
+
1335
+ /** Release a {@link retainSystemQuery} retain. Refcounted like any sub; the LAST release
1336
+ * unregisters from the owning channel, sweeps its buffered frames, and drops the
1337
+ * {@link systemQids} record. The folded lifecycle STATE (`roomWatermarks`/`scopeSessions`/
1338
+ * processed outcomes) deliberately survives — the fence is monotone truth about the store, not
1339
+ * about the subscription (a re-retained fence must not forget a cleared watermark). */
1340
+ releaseSystemQuery(retainQid: QueryId): void {
1341
+ const remoteQid = this.releaseRemote(retainQid);
1342
+ if (remoteQid === undefined) return; // still refcounted (or unknown)
1343
+ for (const gate of this.gates.values()) {
1344
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
1345
+ }
1346
+ this.systemQids.delete(remoteQid);
574
1347
  }
575
1348
 
576
1349
  /** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
@@ -664,31 +1437,130 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
664
1437
  }
665
1438
  }
666
1439
 
1440
+ /** Deal the next wire mid from `domain`'s ledger (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
1441
+ * §7.1) and advance that counter. A domain absent from the map starts at 1. Per-domain, so a
1442
+ * client writing through room + daemon concurrently keeps two gapless, non-aliasing sequences.
1443
+ * The client-global `seq` is stamped in the same breath — the ONE cross-domain total order
1444
+ * (confirmation order is per-domain; replay order is client-global). Bundled here so no call
1445
+ * site can deal a mid without its seq. ONE caller discards the seq deliberately: the H-v deopt
1446
+ * flip ({@link handleMutationOutcome}) keeps the entry's ORIGINAL seq — its replay position —
1447
+ * and takes only the fresh mid (the dealSeq bump is harmless: seq consumers order, never
1448
+ * count). */
1449
+ private dealMid(domain: string): { mid: number; seq: number } {
1450
+ const mid = this.nextMid.get(domain) ?? 1;
1451
+ this.nextMid.set(domain, mid + 1);
1452
+ return { mid, seq: ++this.dealSeq };
1453
+ }
1454
+
1455
+ // --- the DECLARED router (302 §5: declared, not derived) --------------------------------
1456
+ //
1457
+ // The user declares which mutators are room mutators; the client neither proves, derives,
1458
+ // widens, nor falls back. The declaration reaches this backend as `domainPolicy` — the client
1459
+ // layer resolves (mutator name, args) against its declared realtime mutators and the currently
1460
+ // attached rooms. A misdeclaration fails SOFT (302 §5.1): a daemon-declared mutator touching
1461
+ // room-visible data stages onto the daemon tables while the room-homed view reads the room
1462
+ // tables — no optimistic feedback until the echo relays it a hop later, never a divergence.
1463
+ // The room GATE stays the authoritative backstop: a room-routed mutation the room refuses comes
1464
+ // back as a `mutationOutcome` deopt/reject frame and the H-v machinery below re-enqueues or
1465
+ // surfaces it.
1466
+
1467
+ /** The declared confirming stream for one invocation: the `domainPolicy`'s verdict, `"daemon"`
1468
+ * when it abstains. Resolved BEFORE the prediction runs — the domain picks the staging map
1469
+ * (a room domain stages its owned tables onto the room's namespaced twins). */
1470
+ private resolveDomain(name: string, args: unknown): string {
1471
+ return this.domainPolicy(name, args) ?? "daemon";
1472
+ }
1473
+
1474
+ /** The staging table map for a `domain`-routed prediction ({@link trackingTx}'s `stage`):
1475
+ * wire table → the room's namespaced engine table for the tables the room owns; identity for
1476
+ * everything else (including the whole map for the daemon domain). */
1477
+ private stagingMap(domain: string): ReadonlyMap<string, string> | undefined {
1478
+ return domain === "daemon" ? undefined : this.roomTables.get(domain);
1479
+ }
1480
+
1481
+ /** The PLAIN (daemon-homed) engine AST for `ast` — aggregate relationships rewritten to their
1482
+ * synthetic `__agg_*` reads, no room renames. The ONE form every non-swapped engine
1483
+ * registration uses ({@link registerQuery}, {@link dropGhost}'s swap-back) and the base the
1484
+ * swap-in renames ({@link processSwapIns}). */
1485
+ private plainEngineAst(ast: Ast): Ast {
1486
+ return rewriteAggregates(ast, (t) => this.localTables.has(t));
1487
+ }
1488
+
1489
+ /** Mutator names the cross-authority warn below already fired for (once per name). */
1490
+ private readonly warnedCrossAuthority = new Set<string>();
1491
+
1492
+ /** 302 §5.1 dev-time guard: a room-DECLARED mutator wrote tables the room does not own. Those
1493
+ * writes staged onto the PLAIN daemon tables (the staging map covers only owned tables), but
1494
+ * the entry confirms on the ROOM stream — and only the room's OWNED tables flush back to the
1495
+ * daemon, so nothing upstream ever echoes them: once the room confirm retires the entry, the
1496
+ * next release's whole-store rewind reverts them for good. The first-party room shell refuses
1497
+ * such a mutation (the §3.3 deopt/reject backstop re-routes it to the daemon), so this warns
1498
+ * for the shapes where that backstop may be absent (a BYO relay) — loud, once, soft (§5.1:
1499
+ * misdeclarations never throw). */
1500
+ private warnCrossAuthorityWrites(name: string, domain: string, touched: ReadonlySet<string>): void {
1501
+ if (domain === "daemon" || this.warnedCrossAuthority.has(name)) return;
1502
+ const map = this.roomTables.get(domain);
1503
+ const staged = new Set(map?.values() ?? []);
1504
+ const outside = [...touched].filter((t) => !staged.has(t));
1505
+ if (outside.length === 0) return;
1506
+ this.warnedCrossAuthority.add(name);
1507
+ console.warn(
1508
+ `[rindle] room mutator "${name}" wrote table(s) ${outside.join(", ")} that room ${JSON.stringify(domain)} does not own` +
1509
+ ` (owned: ${map !== undefined && map.size > 0 ? [...map.keys()].join(", ") : "none"}) — these writes rely on the room` +
1510
+ ` shell's deopt backstop and revert after the room confirm if the shell applies the mutation anyway (302 §5.1).`,
1511
+ );
1512
+ }
1513
+
667
1514
  /** Run the named client mutator optimistically: the prediction applies to the live
668
1515
  * engine now (affected views update synchronously), `(mid, name, args)` joins the
669
1516
  * pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
670
1517
  invoke(name: string, args: unknown): number {
1518
+ return this.invokeWith(name, args);
1519
+ }
1520
+
1521
+ /** {@link invoke} with an optional PINNED confirming domain (H-v): the deopt handshake's
1522
+ * already-retired arm re-invokes the frame's echoed `(name, args)` as a FRESH invocation pinned
1523
+ * to `"daemon"` — an honest re-prediction on the current base, never derived (`pin` bypasses
1524
+ * {@link resolveDomain} entirely, so the router never runs and no Q6 counter moves). Every
1525
+ * other step is `invoke` verbatim: prediction now, capture, drainOverlapping, mid dealt from
1526
+ * the pinned domain's ledger, envelope on its channel. */
1527
+ private invokeWith(name: string, args: unknown, pin?: string): number {
671
1528
  const mutator = this.registry[name];
672
1529
  if (!mutator) throw new Error(`unknown client mutator: ${name}`);
673
1530
  // One commit boundary spans the prediction AND the `__agg`-head reconcile below, so their views
674
1531
  // (data + count) flush together rather than tearing across two engine commits.
675
1532
  return this.inOneCommit(() => {
676
- // Apply the prediction FIRST. If the mutator throws (client-side validation, a bad read),
1533
+ // The confirming stream is DECLARED (302 §5), so it resolves BEFORE the prediction: the
1534
+ // domain picks the staging map — a room-domain mutator's writes to the room's owned tables
1535
+ // land on the namespaced engine twins the room-homed views read. An H-v deopt re-invocation
1536
+ // pins via `pin` and the policy never runs.
1537
+ const domain = pin ?? this.resolveDomain(name, args);
1538
+ // Apply the prediction. If the mutator throws (client-side validation, a bad read),
677
1539
  // the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
678
1540
  // propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
679
1541
  // silently refuses every later mutation from this client (#10).
680
- const touched = new Set<string>();
1542
+ const writes: WriteSet = new Map();
1543
+ const reads: ReadLog = { reads: [], queries: [] };
681
1544
  const ops: ChildOp[] = [];
682
1545
  this.local.writeWith((tx) => {
683
- this.runMutator(mutator, trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), args);
1546
+ this.runMutator(
1547
+ mutator,
1548
+ trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, this.stagingMap(domain)),
1549
+ args,
1550
+ );
684
1551
  });
1552
+ // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1553
+ const touched = new Set(writes.keys());
1554
+ this.warnCrossAuthorityWrites(name, domain, touched);
685
1555
  // Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
686
1556
  // this write does, so wire order == local-apply order for any pair that can observe each other
687
1557
  // (a read-dependent write reading a folded cell sees the same value optimistically and on the
688
1558
  // wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
689
1559
  this.drainOverlapping(touched);
690
- const mid = this.nextMid++;
691
- this.pendingMutations.push({ mid, name, args, touched });
1560
+ // The confirming stream's ledger deals the mid and its watermark alone retires the entry
1561
+ // (§7.1). An assigned mid pins its domain forever — a re-invocation never re-routes.
1562
+ const { mid, seq } = this.dealMid(domain);
1563
+ this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads });
692
1564
  // The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
693
1565
  // the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
694
1566
  // delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
@@ -696,7 +1568,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
696
1568
  for (const op of ops) this.overlay.observe(op);
697
1569
  this.reconcileAggHead();
698
1570
  this.refreshPending(); // §7.2: this write now touches its queries' pending axis (NOT ResultType).
699
- void this.source.pushMutation({ clientID: this.clientID, mid, name, args });
1571
+ void this.channelFor(domain).pushMutation({ clientID: this.clientID, mid, name, args });
700
1572
  return mid;
701
1573
  });
702
1574
  }
@@ -711,15 +1583,19 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
711
1583
  const foldKey = `${name}\0${stableJson(opts.key)}`;
712
1584
  // One commit boundary spans the prediction AND the `__agg`-head reconcile (see {@link inOneCommit}),
713
1585
  // so a folded mutation's list view and count view flush together, never torn across two commits.
1586
+ // The declared domain (302 §5) — resolved up front, like `invoke`'s: it picks the staging
1587
+ // map, the §9.3 cadence, and the provisional confirming stream (the flush re-resolves).
1588
+ const domain = this.resolveDomain(name, args);
714
1589
  return this.inOneCommit(() => {
715
1590
  // Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
716
1591
  // compute its write is non-absorbing and refused. A throw discards the staged write (clean
717
- // no-op) and consumes no mid — exactly `invoke`'s guarantee.
718
- const touched = new Set<string>();
1592
+ // no-op) and consumes no mid — exactly `invoke`'s guarantee. NO `readLog` here — the trap
1593
+ // path stays byte-for-byte as it was; recording (§3.2 #2) never arms alongside the trap.
1594
+ const writes: WriteSet = new Map();
719
1595
  const ops: ChildOp[] = [];
720
1596
  try {
721
1597
  this.local.writeWith((tx) => {
722
- this.runMutator(mutator, trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops), true), args);
1598
+ this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined, this.stagingMap(domain)), args);
723
1599
  });
724
1600
  } catch (e) {
725
1601
  if (e instanceof FoldReadError) {
@@ -732,18 +1608,32 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
732
1608
  for (const op of ops) this.overlay.observe(op);
733
1609
  this.reconcileAggHead();
734
1610
 
1611
+ // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1612
+ const touched = new Set(writes.keys());
1613
+ this.warnCrossAuthorityWrites(name, domain, touched);
735
1614
  const now = this.clock.now();
736
1615
  let f = this.folds.get(foldKey);
737
1616
  if (f) {
738
1617
  // Overwrite the single entry in place — the pending stack does NOT grow (§1 #2). The head
739
1618
  // already carries this new prediction (absorbing, last-wins on the cell); the entry holds
740
1619
  // only the LATEST args, which is what a rebase re-derives from and what the flush ships.
1620
+ // `domain` too: THIS invocation staged through the freshly-resolved domain's map above, so
1621
+ // a mid-window rebase must re-stage through the same one (the flush re-resolves anyway;
1622
+ // no mid is pinned yet — `entry.mid` is null until flush).
741
1623
  f.entry.args = args;
742
1624
  f.entry.touched = touched;
1625
+ f.entry.writes = writes;
1626
+ f.entry.domain = domain;
743
1627
  f.args = args;
744
1628
  this.clock.clearTimeout(f.timer);
745
1629
  } else {
746
- const entry: PendingMutation = { mid: null, name, args, touched };
1630
+ // §9.3: pick the window's cadence. Routing into a room ⇒ flush at roomDebounceMs so
1631
+ // intermediates stream to the shared head; off the room, the caller's collapse debounce
1632
+ // governs.
1633
+ const inRoom = opts.roomDebounceMs !== undefined && domain !== "daemon";
1634
+ const debounceMs = inRoom ? opts.roomDebounceMs! : opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS;
1635
+ const maxWaitMs = inRoom ? opts.roomDebounceMs! : opts.maxWaitMs;
1636
+ const entry: PendingMutation = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] } };
747
1637
  this.pendingMutations.push(entry);
748
1638
  let resolveMid!: (mid: number) => void;
749
1639
  const midPromise = new Promise<number>((res) => (resolveMid = res));
@@ -752,8 +1642,8 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
752
1642
  args,
753
1643
  timer: undefined,
754
1644
  firstAt: now,
755
- debounceMs: opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS,
756
- maxWaitMs: opts.maxWaitMs,
1645
+ debounceMs,
1646
+ maxWaitMs,
757
1647
  deferAcrossWrites: opts.deferAcrossWrites ?? false,
758
1648
  midPromise,
759
1649
  resolveMid,
@@ -793,12 +1683,172 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
793
1683
  if (!f) return;
794
1684
  this.clock.clearTimeout(f.timer);
795
1685
  this.folds.delete(foldKey);
796
- const mid = this.nextMid++;
1686
+ // Re-resolve the DECLARED confirming stream from the FINAL args (§7.1) and deal the mid from
1687
+ // that domain's ledger — SEND order, never reserved, so gapless within the domain. The mid
1688
+ // dealt below then pins this domain. (A domain that changed since the window opened — a room
1689
+ // attached or dropped mid-window — re-stages on the next reconcile's re-invocation.)
1690
+ const domain = this.resolveDomain(f.entry.name, f.args);
1691
+ f.entry.domain = domain;
1692
+ const { mid, seq } = this.dealMid(domain);
797
1693
  f.entry.mid = mid;
798
- void this.source.pushMutation({ clientID: this.clientID, mid, name: f.entry.name, args: f.args });
1694
+ f.entry.seq = seq;
1695
+ void this.channelFor(domain).pushMutation({ clientID: this.clientID, mid, name: f.entry.name, args: f.args });
799
1696
  f.resolveMid(mid);
800
1697
  }
801
1698
 
1699
+ /** The transport a `domain`-confirmed mutation ships on (§7.5 sent-pins-domain: only the
1700
+ * domain's own authority can confirm it, so its channel is the only correct transport). A
1701
+ * domain with NO connected gate ships on the daemon channel — the gate-less configurations
1702
+ * (`__testRelease`-driven tests) and today's entire live path resolve `"daemon"` anyway. */
1703
+ private channelFor(domain: string): OptimisticSource {
1704
+ return (this.gates.get(domain) ?? this.daemonGate).source;
1705
+ }
1706
+
1707
+ /** The gate a channel-keyed retain registers through (G-iii registration-time routing). The
1708
+ * channel MUST already be connected (`connectSource`; the daemon is constructor-attached) —
1709
+ * loud by design: a typo'd or not-yet-connected sourceKey must throw at retain time, never
1710
+ * silently register on the daemon and split the query's frames across channels. */
1711
+ private requireGate(channel: string): SourceGate {
1712
+ const gate = this.gates.get(channel);
1713
+ if (!gate) {
1714
+ throw new Error(
1715
+ `optimistic backend: no source connected for channel ${JSON.stringify(channel)} — call connectSource(${JSON.stringify(channel)}, source) before retaining a query on it`,
1716
+ );
1717
+ }
1718
+ return gate;
1719
+ }
1720
+
1721
+ /** The channel that owns `sourceQid` — {@link RemoteSub.channel}, the ONE source of truth for
1722
+ * qid routing (G-iii). `undefined` when no sub owns the qid (a harness-delivered raw feed, or
1723
+ * a just-released sub): such frames buffer on whatever gate they arrive at. */
1724
+ private channelOf(sourceQid: QueryId): string | undefined {
1725
+ const key = this.sourceToRemote.get(sourceQid);
1726
+ return key ? this.remoteSubs.get(key)?.channel : undefined;
1727
+ }
1728
+
1729
+ // --- the §3.3 deopt handshake, client half (H-v) ---------------------------------
1730
+ //
1731
+ // THE NAMED INVARIANT (Slice I inherits it): **never retire a room-domain entry off a
1732
+ // daemon-carried lmid without outcome resolution.** On the room socket it holds by
1733
+ // construction: every room lmid folds through the room's OWN gate, whose socket also carries
1734
+ // the outcome frames — same-socket ordering puts the frame before the ack, and the reconnect
1735
+ // re-send re-earns a lost frame, so a room-domain entry is only ever retired as a success when
1736
+ // the room really applied it. Slice I's downgrade path breaks that coupling: the doc-scoped
1737
+ // ledger row becomes readable THROUGH THE DAEMON with no room socket alive (§7.1 "load-bearing
1738
+ // for §7.5"), and an lmid adopted that way covers burnt non-applied mids with no frame to say
1739
+ // so — retiring a deopted entry there as a silent success is exactly the lost-write this
1740
+ // handshake exists to prevent. ENFORCED since I-iii by {@link foldSystemFrames}: the I-ii
1741
+ // outcome ROWS (co-committed, in ONE daemon transaction, with the ledger row that covers them)
1742
+ // are synthesized into frames and routed through THIS machine BEFORE the ledger fold advances
1743
+ // the domain watermark — one verdict path for frames and rows, with the processed set as the
1744
+ // cross-release resolved-verdict memory, and absence-under-a-covering-lmid = applied (I-ii's
1745
+ // atomicity makes that the sound default).
1746
+
1747
+ /** Record `(domain, mid)` as processed; `false` if it already was (a duplicate frame —
1748
+ * ignore it). FIFO-capped per domain ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}). */
1749
+ private markOutcomeProcessed(domain: string, mid: number): boolean {
1750
+ let mids = this.outcomesProcessed.get(domain);
1751
+ if (!mids) this.outcomesProcessed.set(domain, (mids = new Set()));
1752
+ if (mids.has(mid)) return false;
1753
+ mids.add(mid);
1754
+ while (mids.size > MAX_PROCESSED_OUTCOMES_PER_DOMAIN) {
1755
+ mids.delete(mids.values().next().value as number);
1756
+ }
1757
+ return true;
1758
+ }
1759
+
1760
+ /** One `mutationOutcome` frame from `domain`'s channel (H-v — the §3.3 handshake's client
1761
+ * half). The frame arrives OUT-OF-BAND (see {@link attachGate}); the state machine:
1762
+ *
1763
+ * 1. `mid` never issued on `domain` ⇒ ignore (a confused/foreign frame must not invent work).
1764
+ * 2. `(domain, mid)` already processed ⇒ ignore — idempotence under duplicate frames (the
1765
+ * original + a re-send's re-answer; a deopt for a mid whose entry ALREADY FLIPPED also
1766
+ * lands here harmlessly on its second frame).
1767
+ * 3. `kind:"rejected"` ⇒ FINAL. Surface the reason through {@link rejectedHandler} (room-plane
1768
+ * parity with the HTTP queue's callback) and STOP — the drop + snap-back is the EXISTING
1769
+ * failed-mutation machinery: the room burnt the mid, its lmid release retires the entry
1770
+ * per-domain and the reconcile rewinds the prediction, exactly the daemon path's
1771
+ * processed-as-no-op rejection. No new drop path.
1772
+ * 4. `kind:"deopt"`, entry found (pending `(domain, mid)`) ⇒ FLIP IN PLACE: `domain` becomes
1773
+ * `"daemon"`, a fresh daemon mid is dealt and the envelope ships NOW on the daemon channel
1774
+ * ("deal-and-send-now" — the conforming §3.3 re-enqueue: there is no flush machinery for
1775
+ * non-fold entries, so the design's "mid: null until the daemon flush" is satisfied
1776
+ * momentarily inside this call). THE ENTRY'S `seq` IS KEPT — settled (§5.3, commit
1777
+ * 68141096): `seq` is the client-global REPLAY order; re-sequencing would move the entry's
1778
+ * overlay position and change read-dependent SIBLINGS' replay base. Everything else stays
1779
+ * (writes/reads/touched/touchedSources/writeSources — union-never-shrink), the prediction
1780
+ * stays applied (the entry never leaves `pendingMutations`, so no rewind fires), and the
1781
+ * router does NOT re-run nor does `drainOverlapping` (§3.3 re-enqueues, never re-derives;
1782
+ * any open overlapping fold was invoked later and flushes later with a larger mid).
1783
+ * 5. `kind:"deopt"`, entry NOT found ⇒ the burnt-mid confirm won the race, or the frame is a
1784
+ * replay re-answer for an entry a previous session retired (the replay gotcha): re-invoke
1785
+ * the frame's echoed `name`/`args` as a FRESH invocation PINNED to `"daemon"` — an honest
1786
+ * re-prediction on the current base, never a derived route ({@link invokeWith}). A frame
1787
+ * without `name` (not self-contained) has nothing to re-invoke and is dropped; a re-invoke
1788
+ * that THROWS (the base moved from under it) is surfaced through {@link rejectedHandler} —
1789
+ * the mutation is dead with no stream left to confirm it.
1790
+ *
1791
+ * A `"deopt"` bump joins the Q6 routing counters either way (`routing.reasons.deopt`) —
1792
+ * derived-and-deopted routes are visible beside derived successes. */
1793
+ private handleMutationOutcome(domain: string, frame: MutationOutcomeFrame): void {
1794
+ if (frame.mid >= (this.nextMid.get(domain) ?? 1)) return; // never issued here — not ours
1795
+ if (!this.markOutcomeProcessed(domain, frame.mid)) return; // duplicate frame
1796
+ if (frame.kind === "rejected") {
1797
+ const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
1798
+ this.rejectedHandler(
1799
+ {
1800
+ clientID: this.clientID,
1801
+ mid: frame.mid,
1802
+ name: entry?.name ?? frame.name ?? "",
1803
+ args: entry !== undefined ? entry.args : frame.args,
1804
+ },
1805
+ frame.reason ?? "mutation rejected",
1806
+ );
1807
+ return;
1808
+ }
1809
+ // kind === "deopt": the room gate refused a declared-room mutation — re-enqueue onto the daemon.
1810
+ const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
1811
+ if (entry) {
1812
+ entry.domain = "daemon";
1813
+ // Deal the fresh daemon mid but DISCARD its seq — the entry keeps its own (state-machine
1814
+ // step 4 above; the harmless dealSeq bump is accepted). This is the ONE place a dealt seq
1815
+ // is dropped, so "within one domain seq order == mid order" weakens to "except deopt
1816
+ // re-enqueues" — see the {@link PendingMutation.seq} doc.
1817
+ const { mid } = this.dealMid("daemon");
1818
+ entry.mid = mid;
1819
+ void this.channelFor("daemon").pushMutation({ clientID: this.clientID, mid, name: entry.name, args: entry.args });
1820
+ return;
1821
+ }
1822
+ if (frame.name === undefined) return; // not self-contained — nothing to re-invoke
1823
+ try {
1824
+ this.invokeWith(frame.name, frame.args, "daemon");
1825
+ } catch (err) {
1826
+ this.rejectedHandler(
1827
+ { clientID: this.clientID, mid: frame.mid, name: frame.name, args: frame.args },
1828
+ `deopt re-invocation failed: ${String((err as Error)?.message ?? err)}`,
1829
+ );
1830
+ }
1831
+ }
1832
+
1833
+ /** §7.5 rule 3 (H-v): re-send `domain`'s unconfirmed pending envelopes with their ORIGINAL
1834
+ * mids, in mid order, on the domain's own channel. Folds with `mid === null` are excluded —
1835
+ * nothing was ever sent for them (the flush deals their mid). Envelopes are reconstructed from
1836
+ * the pending entries exactly as `invoke` shipped them (`clientID`/`mid`/`name`/`args` —
1837
+ * entries carry everything the wire needs). Idempotent under the domain's ledger: an APPLIED
1838
+ * mid dedups silently and its lmid coverage retires the entry; a NON-APPLIED mid is re-answered
1839
+ * from the shell's recorded-outcome map into {@link handleMutationOutcome}. Confirmed entries
1840
+ * are already gone from `pendingMutations`, so no filter against the watermark is needed. */
1841
+ private resendPending(domain: string): void {
1842
+ const unconfirmed = this.pendingMutations
1843
+ .filter((p) => p.domain === domain && p.mid !== null)
1844
+ .sort((a, b) => a.mid! - b.mid!);
1845
+ if (unconfirmed.length === 0) return;
1846
+ const channel = this.channelFor(domain);
1847
+ for (const p of unconfirmed) {
1848
+ void channel.pushMutation({ clientID: this.clientID, mid: p.mid!, name: p.name, args: p.args });
1849
+ }
1850
+ }
1851
+
802
1852
  /** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
803
1853
  * `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
804
1854
  flushFolds(): void {
@@ -897,7 +1947,17 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
897
1947
  const pending: PendingInspect[] = this.pendingMutations.map((p) => {
898
1948
  const folded = foldByEntry.get(p);
899
1949
  const key = p.mid != null ? `m:${p.mid}` : folded ? `f:${folded[0]}` : `?:${p.name}`;
900
- const out: PendingInspect = { key, mid: p.mid, name: p.name, args: p.args, tables: [...p.touched] };
1950
+ const writes: WriteRecord[] = [];
1951
+ for (const byPk of p.writes.values()) for (const rec of byPk.values()) writes.push(rec);
1952
+ const out: PendingInspect = {
1953
+ key,
1954
+ mid: p.mid,
1955
+ name: p.name,
1956
+ args: p.args,
1957
+ tables: [...p.touched],
1958
+ writes,
1959
+ reads: { reads: [...p.reads.reads], queries: [...p.reads.queries] },
1960
+ };
901
1961
  if (folded) {
902
1962
  const f = folded[1];
903
1963
  out.fold = {
@@ -912,14 +1972,71 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
912
1972
  });
913
1973
  return {
914
1974
  pending,
915
- confirmedLmid: this.confirmedLmid,
916
- nextMid: this.nextMid,
917
- appliedCv: this.appliedCv,
918
- bufferedFrames: this.buffer.length,
1975
+ // Back-compat scalars for the devtools `OptimisticInspect` mirror (unchanged shape): the DAEMON
1976
+ // domain's ledger — the only one in single-domain. Per-domain state is `__inspectDomains()`.
1977
+ confirmedLmid: this.watermark.get("daemon") ?? 0,
1978
+ nextMid: this.nextMid.get("daemon") ?? 1,
1979
+ appliedCv: this.daemonGate.appliedCv,
1980
+ bufferedFrames: this.daemonGate.buffer.length,
919
1981
  pendingTables: [...this.pendingTables()],
920
1982
  };
921
1983
  }
922
1984
 
1985
+ /** Test-only per-domain ledger snapshot (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1/§8.5).
1986
+ * Kept separate from {@link __inspect} so the devtools `OptimisticInspect` mirror stays byte-for-
1987
+ * byte identical (daemon-scalar-only). Exposes the per-domain `nextMid`/`watermark` maps plus each
1988
+ * pending entry's confirming domain — the axes the §8.5 ledger-isolation assertion checks. */
1989
+ __inspectDomains(): {
1990
+ nextMid: Record<string, number>;
1991
+ watermark: Record<string, number>;
1992
+ /** Per connected CHANNEL (§5.1): its release watermark + buffered-frame depth — the axis the
1993
+ * gate-isolation assertions read (one source's laggy cvMin must never move the other's). */
1994
+ gates: Record<string, { appliedCv: number; bufferedFrames: number }>;
1995
+ /** Per connected/registered room: its wire-table → engine-table map (302 §2) and which local
1996
+ * view qids are currently swapped onto it (302 §4). */
1997
+ roomTables: Record<string, Record<string, string>>;
1998
+ swappedViews: Record<number, string>;
1999
+ /** The §4 lifecycle plane's folded state (Slice I-iii introspection): the per-doc §4.2 fence
2000
+ * value (`roomWatermarks`, I-v's ghost-drop input), the per-scope §4.1 occupancy map
2001
+ * (`scopeSessions`: scope → client_id → expires_at, I-iv's doorbell input), and the live
2002
+ * I-v ghosts (demoted room sources still awaiting their swap-back fence). */
2003
+ lifecycle: {
2004
+ roomWatermarks: Record<string, number>;
2005
+ scopeSessions: Record<string, Record<string, number>>;
2006
+ ghosts: Record<string, { doc: string; finalFlushSeq: number }>;
2007
+ };
2008
+ pending: { mid: number | null; seq: number | null; name: string; domain: string }[];
2009
+ } {
2010
+ return {
2011
+ nextMid: Object.fromEntries(this.nextMid),
2012
+ watermark: Object.fromEntries(this.watermark),
2013
+ gates: Object.fromEntries(
2014
+ [...this.gates].map(([k, g]) => [k, { appliedCv: g.appliedCv, bufferedFrames: g.buffer.length }]),
2015
+ ),
2016
+ roomTables: Object.fromEntries(
2017
+ [...this.roomTables].map(([k, m]) => [k, Object.fromEntries(m)]),
2018
+ ),
2019
+ swappedViews: Object.fromEntries(this.roomSwappedViews),
2020
+ lifecycle: {
2021
+ roomWatermarks: Object.fromEntries(this.roomWatermarks),
2022
+ scopeSessions: Object.fromEntries(
2023
+ [...this.scopeSessions].map(([scope, sessions]) => [scope, Object.fromEntries(sessions)]),
2024
+ ),
2025
+ ghosts: Object.fromEntries(
2026
+ [...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq }]),
2027
+ ),
2028
+ },
2029
+ pending: this.pendingMutations.map((p) => ({
2030
+ mid: p.mid,
2031
+ // The client-global deal sequence — the REPLAY order (mids are per-domain, incomparable
2032
+ // across domains; see PendingMutation.seq). The harness asserts send order with this.
2033
+ seq: p.seq,
2034
+ name: p.name,
2035
+ domain: p.domain,
2036
+ })),
2037
+ };
2038
+ }
2039
+
923
2040
  /** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
924
2041
  * from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
925
2042
  * (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
@@ -933,18 +2050,38 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
933
2050
  }
934
2051
  }
935
2052
 
936
- // --- the downstream stream (§8.5: buffer, then release coherently) ---------------
937
-
938
- private onNormalized(qid: QueryId, ev: NormalizedEvent): void {
939
- if (qid !== LMID_QID) this.emitServerDelta(qid, ev);
2053
+ // --- the downstream stream (§8.5: buffer, then release coherently — PER GATE, §5.1) ------
2054
+
2055
+ private onFrame(gate: SourceGate, qid: QueryId, ev: NormalizedEvent): void {
2056
+ // Ownership is fixed at RETAIN time (G-iii registration-time routing: {@link RemoteSub.channel},
2057
+ // the one source of truth) — a qid lives on the ONE channel its sub registered on. So a frame
2058
+ // arriving on any OTHER gate means the server routed a qid to the wrong channel: a wiring bug —
2059
+ // fail loudly rather than silently splitting one query's frames across two cv timelines. (This
2060
+ // used to be a lazy first-arrival CLAIM; it is now a pure assertion.) A qid with NO sub (a
2061
+ // harness-delivered raw feed) has no owner and buffers on the arriving gate; the per-channel
2062
+ // reserved LMID_QID is exempt — each gate owns its own.
2063
+ if (qid !== LMID_QID) {
2064
+ const owner = this.channelOf(qid);
2065
+ if (owner !== undefined && owner !== gate.key) {
2066
+ // I-iv retarget grace: a frame already in flight from the sub's PREVIOUS channel when
2067
+ // {@link retargetRemoteQuery} moved it (the unsubscribe races the server's last frames)
2068
+ // is stale, not a wiring bug — drop it. The grace window is exactly the deferred-GC
2069
+ // window: {@link flushRetargetGc} deletes the record, and the loud throw is restored.
2070
+ if (this.pendingRetargetGc.get(qid) === gate.key) return;
2071
+ throw new Error(`optimistic backend: qid ${qid} arrived on ${gate.key} but is owned by ${owner}`);
2072
+ }
2073
+ }
2074
+ // System-plane frames (I-iii) are bookkeeping, not view data: like the lmid stream they skip
2075
+ // the devtools server-delta tap (there is no local view to attribute them to).
2076
+ if (qid !== LMID_QID && !this.systemQids.has(qid)) this.emitServerDelta(qid, ev);
940
2077
  if (ev.type === "hello") {
941
2078
  // A hello is a (re)subscribe = a NEW epoch. The column map below is mutated eagerly, but
942
- // data frames are cv-buffered and drained later (onProgress). So any frame still buffered
943
- // for this qid is from a SUPERSEDED epoch and must NOT be scattered through this epoch's
944
- // (possibly changed) map — drop it. This epoch's snapshot, which always follows the hello,
945
- // re-hydrates the qid from scratch, so the dropped frames are redundant. Scoped to this
946
- // qid: other queries' frames (and the lmid system query's) keep their coherent release.
947
- this.buffer = this.buffer.filter((f) => f.qid !== qid);
2079
+ // data frames are cv-buffered and drained later (the gate's progress). So any frame still
2080
+ // buffered for this qid is from a SUPERSEDED epoch and must NOT be scattered through this
2081
+ // epoch's (possibly changed) map — drop it. This epoch's snapshot, which always follows the
2082
+ // hello, re-hydrates the qid from scratch, so the dropped frames are redundant. Scoped to
2083
+ // this qid: other queries' frames (and the lmid system query's) keep their coherent release.
2084
+ gate.buffer = gate.buffer.filter((f) => f.qid !== qid);
948
2085
  this.addServerDependencyTables(qid, ev.tables.map((t) => t.name));
949
2086
  // Learn this query's per-table column map (PROJECTION-SUPPORT-DESIGN.md §5.2): map each
950
2087
  // advertised column to its base ColId BY NAME. The hello may carry FEWER columns than the
@@ -962,15 +2099,15 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
962
2099
  // Register a non-trivial map; otherwise revert to '*' — and CLEAR any stale map a prior
963
2100
  // epoch left (a server that expanded then contracted back), so the now-exact rows don't
964
2101
  // scatter through a `-1`-bearing layout (silent cell corruption).
965
- if (cols.length !== full || cols.some((c, i) => c !== i)) this.sync.registerProjection(qid, t.name, cols);
966
- else this.sync.unregisterProjection(qid, t.name);
2102
+ if (cols.length !== full || cols.some((c, i) => c !== i)) gate.sync.registerProjection(qid, t.name, cols);
2103
+ else gate.sync.unregisterProjection(qid, t.name);
967
2104
  }
968
2105
  return; // envelope validation is the source's job
969
2106
  }
970
2107
  const cv = ev.cv ?? 0;
971
- if (cv <= this.appliedCv && ev.type === "batch") return; // stale redelivery
972
- this.buffer.push({ cv, qid, kind: ev.type, ops: ev.ops, seq: this.nextSeq++ });
973
- if (this.buffer.length > this.bufferCap) this.overflow();
2108
+ if (cv <= gate.appliedCv && ev.type === "batch") return; // stale redelivery ON THIS TIMELINE
2109
+ gate.buffer.push({ cv, qid, kind: ev.type, ops: ev.ops, seq: gate.nextSeq++ });
2110
+ if (gate.buffer.length > this.bufferCap) this.overflow(gate);
974
2111
  }
975
2112
 
976
2113
  private emitServerDelta(sourceQid: QueryId, ev: NormalizedEvent): void {
@@ -988,89 +2125,423 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
988
2125
  return localQids.length ? localQids : [sourceQid];
989
2126
  }
990
2127
 
991
- private onProgress(frame: ProgressFrame): void {
2128
+ /** One gate's release (§5.1 release gate): compute the coherent delta from THIS gate's cv-buffer,
2129
+ * then apply it against the gate's source/domain. Split into {@link computeRelease} (buffer →
2130
+ * delta, lmid → watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
2131
+ * N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
2132
+ private onGateProgress(gate: SourceGate, frame: ProgressFrame): void {
2133
+ const { deltas, newlyHydrated, touchedScopes } = this.computeRelease(gate, frame);
2134
+ this.applyRelease(gate.key, deltas, undefined, newlyHydrated);
2135
+ // I-iv phase 2: a retargeted sub whose first ROOM snapshot released just now gets its old
2136
+ // channel's rows GC'd — AFTER the release fully applied, so the winner flip is value-equal
2137
+ // against the freshly-folded room rows (never a remove-before-the-refill).
2138
+ this.flushRetargetGc(gate);
2139
+ // I-iv doorbell events LAST — everything this release carried (data, confirms, the occupancy
2140
+ // fold itself, the retarget cutover) is already applied when the consumer's reaction (an
2141
+ // async re-lease) is kicked off. One event per touched scope, count evaluated at the fold
2142
+ // clock's now (deterministic under an injected clock).
2143
+ if (touchedScopes !== null) {
2144
+ for (const scope of touchedScopes) {
2145
+ this.scopeSessionsHandler({ scope, others: this.otherScopeSessions(scope) });
2146
+ }
2147
+ }
2148
+ }
2149
+
2150
+ /** Compute one coherent release from ONE gate's cv-buffer (§5.1) — gate-scoped: its buffer, its
2151
+ * cvMin timeline. Take every buffered frame at `cv ≤ cvMin`, in (cv, arrival) order, and fold
2152
+ * it: the lmid system-query frame advances `watermark[gate.key]` (via {@link foldLmidOps} — the
2153
+ * daemon stream folds "daemon", a room stream folds its own domain); data frames fold through
2154
+ * this SOURCE's cross-query refcount into ONE net base delta — the §1.3 `D`. Returns that delta
2155
+ * plus the set of local views this release JUST hydrated (so their reconcile batch phases as a
2156
+ * `snapshot`). Mutates the gate's buffer/`appliedCv`, hydration, and the gate's domain
2157
+ * watermark; the pending set and the reconcile are {@link applyRelease}'s job. */
2158
+ private computeRelease(
2159
+ gate: SourceGate,
2160
+ frame: ProgressFrame,
2161
+ ): { deltas: Mutation[]; newlyHydrated: Set<QueryId> | null; touchedScopes: Set<string> | null } {
992
2162
  // Snapshot which local views are already hydrated BEFORE this release folds: any that cross into
993
2163
  // hydrated below get their first result set as this cycle's batch, which must phase as a snapshot.
994
2164
  const wasHydrated = new Set(this.hydrated);
995
- // (1) Take every buffered frame at cv ≤ cvMin, in (cv, arrival) order, and fold it:
996
- // lmid system-query frames advance `confirmedLmid`; data frames fold through the
997
- // cross-query refcount into ONE net base delta — the §1.3 `D`. Confirmation and
998
- // data of the same commit share a cv, so they release together by construction.
999
- const ready = this.buffer
2165
+ const ready = gate.buffer
1000
2166
  .filter((f) => f.cv <= frame.cvMin)
1001
2167
  .sort((a, b) => a.cv - b.cv || a.seq - b.seq);
1002
- this.buffer = this.buffer.filter((f) => f.cv > frame.cvMin);
2168
+ gate.buffer = gate.buffer.filter((f) => f.cv > frame.cvMin);
2169
+ // The §4 lifecycle SYSTEM frames fold FIRST, in a FIXED structural category order (Slice
2170
+ // I-iii; see {@link foldSystemFrames} for why the order is load-bearing), then the ordinary
2171
+ // lmid + data frames fold exactly as before. With no system retain the partition is empty and
2172
+ // this release is byte-identical to pre-I-iii. The returned scope set feeds the I-iv doorbell
2173
+ // events `onGateProgress` fires once the WHOLE release has applied.
2174
+ const touchedScopes = this.foldSystemFrames(ready.filter((f) => this.systemQids.has(f.qid)));
1003
2175
  const muts: Mutation[] = [];
1004
2176
  for (const f of ready) {
2177
+ if (this.systemQids.has(f.qid)) {
2178
+ // Folded above; a system stream has no store view and MUST NOT enter the sync layer (its
2179
+ // tables are not in the schema) — but its first snapshot still marks the sub hydrated so
2180
+ // the overflow/introspection bookkeeping stays uniform.
2181
+ if (f.kind === "snapshot") this.markSubHydrated(f.qid);
2182
+ continue;
2183
+ }
1005
2184
  if (f.qid === LMID_QID) {
1006
- this.foldLmidOps(f.ops);
2185
+ // Confirmation and data of the same commit share a cv, so they release together — each
2186
+ // channel's lmid stream folds into ITS OWN domain's watermark (§7.1).
2187
+ this.foldLmidOps(f.ops, gate.key);
1007
2188
  continue;
1008
2189
  }
2190
+ // A ROOM gate's deltas rename into the room's namespaced tables — and a wire table outside
2191
+ // the registered map is DROPPED (302 §6: context comes from the daemon, one authority per
2192
+ // table; a room's relayed context copy must never enter the store).
1009
2193
  muts.push(
1010
- ...(f.kind === "snapshot" ? this.sync.rehydrate(f.qid, f.ops) : this.sync.applyBatch(f.qid, f.ops)),
2194
+ ...mapGateDeltas(gate, f.kind === "snapshot" ? gate.sync.rehydrate(f.qid, f.ops) : gate.sync.applyBatch(f.qid, f.ops)),
1011
2195
  );
1012
2196
  // A query's first released snapshot is its hydration point — even an empty one (0 rows is an
1013
2197
  // authoritative answer): lift every local view this sub feeds out of `unknown` (loading).
1014
2198
  if (f.kind === "snapshot") this.markSubHydrated(f.qid);
1015
2199
  }
1016
- this.appliedCv = Math.max(this.appliedCv, frame.cvMin);
2200
+ gate.appliedCv = Math.max(gate.appliedCv, frame.cvMin);
2201
+ let newlyHydrated: Set<QueryId> | null = null;
2202
+ for (const qid of this.hydrated) {
2203
+ if (!wasHydrated.has(qid)) (newlyHydrated ??= new Set()).add(qid);
2204
+ }
2205
+ return { deltas: muts, newlyHydrated, touchedScopes };
2206
+ }
1017
2207
 
1018
- // (2) Drop confirmed pending (§1.3 step 5's bookkeeping half): mid the lmid this
1019
- // release itself delivered. A failed mutation drops the same way — the release just
1020
- // carries no effects for it, so the rewind in (3) snaps the prediction back. An UNFLUSHED
1021
- // folded entry (`mid == null`) is never confirmable it has not crossed the wire — so it is
1022
- // always retained until its own flush stamps a real mid (FOLDED-MUTATIONS-DESIGN §4.1).
2208
+ /** Apply one released delta against `sourceKey`'s domain (§7.2 per-domain confirm-drop + the §1.3
2209
+ * reconcile cycle). `watermarkUpdate`, when given, advances `watermark[sourceKey]` first — the
2210
+ * hook a per-source lmid confirm rides on (the daemon path folds its watermark in
2211
+ * {@link computeRelease} and passes `undefined`). Then: drop every pending entry its OWN domain's
2212
+ * watermark now covers (a room confirm can never retire a daemon entry, and vice-versa — the §7.1
2213
+ * ledger-collision fix), and run the reconcile cycle against `sourceKey` when the base delta or the
2214
+ * pending set changed. `newlyHydrated` stamps the initial-hydration batch as a catch-up. */
2215
+ private applyRelease(
2216
+ sourceKey: string,
2217
+ deltas: Mutation[],
2218
+ watermarkUpdate?: number,
2219
+ newlyHydrated: Set<QueryId> | null = null,
2220
+ ): void {
2221
+ if (watermarkUpdate !== undefined) {
2222
+ this.watermark.set(sourceKey, Math.max(this.watermark.get(sourceKey) ?? 0, watermarkUpdate));
2223
+ }
2224
+ // Drop confirmed pending (§1.3 step 5's bookkeeping half), PER DOMAIN: an entry is retired only
2225
+ // when ITS domain's watermark reaches its mid — so two concurrent streams never alias one counter
2226
+ // (§7.1). A failed mutation drops the same way (the release carries no effects, so the rewind snaps
2227
+ // the prediction back). An UNFLUSHED fold (`mid == null`) is never confirmable — retained until its
2228
+ // flush stamps a real mid (FOLDED-MUTATIONS-DESIGN §4.1), regardless of any domain's watermark.
2229
+ // H-v NOTE — retiring here treats coverage as SUCCESS, which for a room domain is sound only
2230
+ // because outcome resolution ALWAYS precedes the coverage that retires: on the room socket
2231
+ // the outcome frames outrun the lmid acks (same-socket ordering + the resync re-send), and on
2232
+ // the daemon-carried path (I-iii) `foldSystemFrames` routes the co-committed outcome ROWS
2233
+ // through handleMutationOutcome BEFORE the ledger fold advances the watermark this filter
2234
+ // reads — a deopted entry has already flipped off the domain by the time its burnt mid is
2235
+ // covered, either way (the named invariant above handleMutationOutcome).
1023
2236
  const before = this.pendingMutations.length;
1024
- this.pendingMutations = this.pendingMutations.filter((p) => p.mid === null || p.mid > this.confirmedLmid);
2237
+ this.pendingMutations = this.pendingMutations.filter(
2238
+ (p) => p.mid === null || p.mid > (this.watermark.get(p.domain) ?? 0),
2239
+ );
1025
2240
  const pendingChanged = this.pendingMutations.length !== before;
1026
2241
 
1027
- // (3) The reconcile cycle — only when something can have changed: a base delta to
1028
- // fold in, or a pending set that shrank (its optimistic layer must rewind out). The batch it
1029
- // emits for any view that JUST became hydrated is that view's initial result set, so mark those
1030
- // qids so the local-event forwarder stamps their batch `catchUp` (→ Store phases it `snapshot`).
1031
- if (muts.length || pendingChanged) {
1032
- let newlyHydrated: Set<QueryId> | null = null;
1033
- for (const qid of this.hydrated) {
1034
- if (!wasHydrated.has(qid)) (newlyHydrated ??= new Set()).add(qid);
1035
- }
2242
+ // The reconcile cycle — only when something can have changed: a base delta to fold in, or a
2243
+ // pending set that shrank (its optimistic layer must rewind out). The batch it emits for any view
2244
+ // that JUST became hydrated is that view's initial result set, so mark those qids so the
2245
+ // local-event forwarder stamps their batch `catchUp` (→ Store phases it `snapshot`).
2246
+ if (deltas.length || pendingChanged) {
2247
+ const emitted = (this.catchUpEmitted = new Set<QueryId>());
1036
2248
  this.catchUpQids = newlyHydrated;
1037
2249
  try {
1038
- this.runReconcileCycle(muts);
2250
+ this.runReconcileCycle(sourceKey, deltas);
1039
2251
  } finally {
1040
2252
  this.catchUpQids = null;
2253
+ this.catchUpEmitted = null;
1041
2254
  }
2255
+ // Drop the qids the reconcile actually delivered a batch for; the rest folded nothing.
2256
+ if (newlyHydrated) for (const qid of emitted) newlyHydrated.delete(qid);
1042
2257
  }
1043
2258
 
1044
- // (4) ResultType is the SERVER CHANNEL's state only now (§7): `unknown` while not hydrated,
1045
- // else `complete` — a pending mutation no longer moves it. The pending axis moves separately.
2259
+ // ResultType is the SERVER CHANNEL's state only now (§7): `unknown` while not hydrated, else
2260
+ // `complete` — a pending mutation no longer moves it. The pending axis moves separately.
1046
2261
  for (const qid of this.queryTables.keys()) {
1047
2262
  this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
1048
2263
  }
2264
+ // A newly-hydrated query whose reconcile emitted NO batch (0 rows, its whole result already present
2265
+ // via a sibling → 0 net muts, or the reconcile was skipped) still needs a hydration signal, or its
2266
+ // SSR seed never retires and the view freezes. Send an explicit empty catch-up (now that it reads
2267
+ // `complete`, the Store retires the seed and reveals whatever is already in its tree).
2268
+ if (newlyHydrated) {
2269
+ for (const qid of newlyHydrated) this.handler(qid, { type: "batch", events: [], catchUp: true });
2270
+ }
1049
2271
  this.refreshPending();
2272
+ // The 302 §4.1 swap-in — strictly AFTER the reconcile above folded this release's data, so a
2273
+ // room sub whose first snapshot just released swaps its views onto room tables that already
2274
+ // hold the snapshot (swapping earlier would hydrate them empty). Structural no-op with no
2275
+ // pending swap (every single-domain client).
2276
+ this.processSwapIns();
2277
+ // The I-v ghost-drop watcher (§4.2), LAST: this release's watermark rows have folded
2278
+ // (computeRelease) and its confirm-drop has retired what it covers — exactly the two inputs
2279
+ // the drop condition reads. Structural no-op with no ghost.
2280
+ this.evaluateGhosts();
2281
+ }
2282
+
2283
+ /** Test-only per-source release seam (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.2/§8.5): drive
2284
+ * {@link applyRelease} for `sourceKey` directly — an explicit `watermarkUpdate` (a simulated lmid
2285
+ * confirm for that domain) and `deltas` (a coherent base delta), with no real gate. Lets a harness
2286
+ * exercise a room-domain confirm before the real second lmid stream / per-source gate is wired
2287
+ * (E-iii-b/c). The `__`-prefix marks it a test hook, alongside {@link __inspect}. */
2288
+ __testRelease(sourceKey: string, deltas: Mutation[], watermarkUpdate?: number): void {
2289
+ this.applyRelease(sourceKey, deltas, watermarkUpdate);
2290
+ }
2291
+
2292
+ // --- the 302 §4 swap-in ------------------------------------------------------------------
2293
+
2294
+ /** Swap every view of each just-hydrated ROOM sub onto the room's namespaced tables (302 §4.1):
2295
+ * re-register the local engine query with the AST's room-owned table references renamed
2296
+ * ({@link remapAstTables}); the Store folds the re-hello as an in-place reset, so the caller's
2297
+ * view reference survives and subscribers see ONE transition. Runs at the applyRelease tail —
2298
+ * the reconcile has already folded the sub's snapshot into the room tables, so the swapped
2299
+ * view hydrates straight to the room state (swapping earlier would flash it empty). The
2300
+ * ORIGINAL ast stays in {@link asts}; the swap-back ({@link dropGhost}) re-registers it.
2301
+ *
2302
+ * This is the accepted-flash boundary (302 §4.1/§7.1): the room's copy may be behind the
2303
+ * daemon rows the view showed a moment ago — accepted by decision, revisit on a real
2304
+ * two-region deploy. */
2305
+ private processSwapIns(): void {
2306
+ if (this.pendingSwapIns.size === 0) return; // every single-domain release: structural no-op
2307
+ const subs = [...this.pendingSwapIns];
2308
+ this.pendingSwapIns.clear();
2309
+ this.inOneCommit(() => {
2310
+ for (const sub of subs) {
2311
+ const map = this.roomTables.get(sub.channel);
2312
+ for (const qid of sub.localQids.keys()) {
2313
+ const ast = this.asts.get(qid);
2314
+ if (ast === undefined) continue;
2315
+ // 302 §6.1 coverage check, BEFORE the owned-table gate (an all-context room swaps
2316
+ // nothing yet still starves every ref): any referenced table the room does not own
2317
+ // keeps reading the PLAIN daemon tables after the swap — legal (the client-side join
2318
+ // across kinds), but the rows render only if a daemon subscription covers them, which
2319
+ // is unknowable here. Surface once per view, loudly, so a silently-empty join is a
2320
+ // named condition. Local-only tables are daemon-free by definition — skip them.
2321
+ if (!this.contextJoinWarned.has(qid)) {
2322
+ const uncovered = [...collectTables(ast)].filter((t) => !(map?.has(t) ?? false) && !this.localTables.has(t));
2323
+ if (uncovered.length > 0) {
2324
+ this.contextJoinWarned.add(qid);
2325
+ this.roomContextJoinHandler({ sourceKey: sub.channel, name: sub.remote.name, args: sub.remote.args, tables: uncovered });
2326
+ }
2327
+ }
2328
+ if (map === undefined || map.size === 0) continue; // no owned tables — nothing to swap
2329
+ if (this.roomSwappedViews.get(qid) === sub.channel) continue; // already swapped
2330
+ const rewritten = remapAstTables(this.plainEngineAst(ast), map);
2331
+ this.local.unregisterQuery(qid);
2332
+ this.local.registerQuery(qid, rewritten);
2333
+ this.roomSwappedViews.set(qid, sub.channel);
2334
+ // The pending axis follows the engine tables the view now reads (union — the wire
2335
+ // names stay too, conservatively: a daemon-declared write to a room-visible table is
2336
+ // still an honest "pending elsewhere" signal).
2337
+ const tables = this.queryTables.get(qid);
2338
+ if (tables) for (const t of map.values()) tables.add(t);
2339
+ }
2340
+ }
2341
+ });
1050
2342
  }
1051
2343
 
1052
- /** Fold the lmid system query's released ops (lmid-as-data): the one row's
1053
- * `last_mutation_id` cell is this client's confirmed high-water mid. */
1054
- private foldLmidOps(ops: NormalizedOp[]): void {
2344
+ /** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
2345
+ * `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
2346
+ * `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
2347
+ * daemon stream folds `"daemon"`; a room stream folds its own `"room:doc:X"`; the daemon-carried
2348
+ * §7.1 ledger rows fold through the same {@link foldConfirm} core (Slice I-iii). */
2349
+ private foldLmidOps(ops: NormalizedOp[], domain: string): void {
1055
2350
  for (const op of ops) {
1056
2351
  const row = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
1057
2352
  if (!row) continue; // a remove (client GC) confirms nothing
1058
- const lmid = Number(row[1]);
1059
- if (!Number.isFinite(lmid)) continue;
1060
- if (lmid > this.nextMid - 1) {
1061
- if (this.pendingMutations.length > 0) {
1062
- // The server confirmed a mid we never issued while we have mutations in
1063
- // flight two writers on one clientID or corrupted state. Unrecoverable.
1064
- throw new Error(
1065
- `optimistic backend: confirmed lmid ${lmid} is ahead of issued mids (${this.nextMid - 1})`,
1066
- );
2353
+ this.foldConfirm(domain, Number(row[1]));
2354
+ }
2355
+ }
2356
+
2357
+ /** THE one confirm fold (§7.1/§7.2): advance `watermark[domain]` to `lmid` (monotone max) and,
2358
+ * on a fresh session ahead of our issued mids, adopt `nextMid[domain]`. Shared verbatim by the
2359
+ * per-channel lmid system query ({@link foldLmidOps}) and the daemon-carried room-ledger rows
2360
+ * ({@link foldSystemFrames} one core so the two paths cannot drift). */
2361
+ private foldConfirm(domain: string, lmid: number): void {
2362
+ if (!Number.isFinite(lmid)) return;
2363
+ const highestIssued = (this.nextMid.get(domain) ?? 1) - 1;
2364
+ if (lmid > highestIssued) {
2365
+ // Only in-flight mutations of THIS domain can contradict its watermark (§7.1: the counters
2366
+ // are independent — a room-domain mutation pending while the daemon's historical lmid
2367
+ // snapshot arrives is a normal fresh-session interleaving, not a second writer). An
2368
+ // unflushed fold (`mid == null`) has issued nothing yet either way: it cannot explain a
2369
+ // confirmed-ahead lmid, and its eventual flush deals from the adopted counter below.
2370
+ if (this.pendingMutations.some((p) => p.domain === domain && p.mid !== null)) {
2371
+ // The server confirmed a mid we never issued while we have mutations in
2372
+ // flight on this domain — two writers on one clientID or corrupted state. Unrecoverable.
2373
+ throw new Error(
2374
+ `optimistic backend: confirmed lmid ${lmid} is ahead of issued mids (${highestIssued})`,
2375
+ );
2376
+ }
2377
+ // A fresh session over a clientID with history: adopt the server's high-water
2378
+ // mark so our next mid continues the sequence instead of colliding below it.
2379
+ this.nextMid.set(domain, lmid + 1);
2380
+ }
2381
+ this.watermark.set(domain, Math.max(this.watermark.get(domain) ?? 0, lmid));
2382
+ }
2383
+
2384
+ // --- the §4 lifecycle system-stream folds (Slice I-iii) --------------------------------
2385
+
2386
+ /** Fold one release's SYSTEM frames in a FIXED category order — the order is STRUCTURAL (one
2387
+ * function, categories in sequence), because it is the client half of THE NAMED INVARIANT
2388
+ * (§3.3's shipped note; documented above {@link handleMutationOutcome}): **never retire a
2389
+ * room-domain entry off a daemon-carried lmid without outcome resolution.**
2390
+ *
2391
+ * 1. **outcome rows** (`_rindle_room_mutation_outcomes`) — each row for OUR clientID is
2392
+ * synthesized into a {@link MutationOutcomeFrame} and routed through
2393
+ * {@link handleMutationOutcome}, the SAME H-v state machine the room socket's frames use
2394
+ * (one verdict path: frames and rows cannot drift). A deopt flips its pending entry to
2395
+ * the daemon IN PLACE (keep-seq, deal-and-send-now); a rejection surfaces + stays for the
2396
+ * ordinary burnt-mid retire; a duplicate (frame already seen, or the row re-delivered) is
2397
+ * absorbed by the processed set — which doubles as the resolved-verdict memory across
2398
+ * releases (per-domain FIFO, {@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}, mirroring the
2399
+ * shell's recorded-outcome cap).
2400
+ * 2. **room-ledger rows** (`_rindle_room_client_mutations`) — the FIRST daemon-carried
2401
+ * room-lmid path: OUR row's `last_mutation_id` folds into `watermark[room:<doc>]` via
2402
+ * {@link foldConfirm}. Because step 1 ALREADY resolved every non-applied verdict this
2403
+ * release carries (and earlier releases' verdicts were resolved at their own release),
2404
+ * the confirm-drop that follows in {@link applyRelease} retires only entries whose
2405
+ * outcome is resolution-by-absence — which I-ii's co-commit atomicity defines as APPLIED
2406
+ * (a room flush co-commits the ledger row and every non-applied mid's outcome row in ONE
2407
+ * daemon transaction, so a covering lmid without a row IS the applied verdict).
2408
+ * Processing this category before step 1 is the violation, in two proven directions
2409
+ * (each run break→fail→revert against `test/system_streams.test.ts`): (a) the ledger's
2410
+ * fresh-session `nextMid` ADOPTION must not run before historical outcome rows are
2411
+ * judged — adopted-first, a previous session's retained deopt row passes the
2412
+ * "never-issued" guard and spuriously re-invokes a mutation that session already handled
2413
+ * (a double-apply); (b) the RETIRE must not precede resolution — it does not BECAUSE the
2414
+ * confirm-drop runs in {@link applyRelease}, strictly after this whole function. That
2415
+ * deferral is load-bearing: an "optimization" retiring inline with the watermark fold
2416
+ * retires a deopted entry as a silent success (the exact lost-write H-v exists to
2417
+ * prevent) and mis-attributes a rejected row's reason.
2418
+ * 3. **watermark rows** (`_rindle_room_watermark`) — the §4.2 fence value, max-folded per
2419
+ * doc ({@link roomWatermarks}); I-v's ghost-drop consumer, no reaction here.
2420
+ * 4. **scope-session rows** (`_rindle_scope_sessions`) — the §4.1 occupancy map
2421
+ * ({@link scopeSessions}); I-iv's doorbell consumer, no reaction here.
2422
+ *
2423
+ * Ordinary data ops fold AFTER all of these (the caller's main loop) — outcome/ledger state
2424
+ * must be in place before {@link applyRelease}'s confirm-drop + reconcile consume the release.
2425
+ * Every row is filtered against the retain's {@link SystemStreamSpec} scope/doc AND (for the
2426
+ * client-keyed tables) our own `clientID` — defense in depth: the server predicate may have
2427
+ * been minted doc-only (no `clientId` at lease time), so other clients' rows are expected and
2428
+ * must be ignored, and a row for a doc this retain was not minted for is never folded.
2429
+ *
2430
+ * Returns the scopes category 4 touched (snapshot or ops) — the I-iv doorbell events' input;
2431
+ * `null` when none (every non-lifecycle release). The events themselves fire from
2432
+ * `onGateProgress` AFTER the release applies, never from inside the fold. */
2433
+ private foldSystemFrames(frames: BufferedFrame[]): Set<string> | null {
2434
+ if (frames.length === 0) return null;
2435
+ const byTable = (table: string): { spec: SystemStreamSpec; frame: BufferedFrame }[] =>
2436
+ frames.flatMap((frame) => {
2437
+ const spec = this.systemQids.get(frame.qid);
2438
+ return spec !== undefined && spec.table === table ? [{ spec, frame }] : [];
2439
+ });
2440
+ // (1) outcome rows → the H-v machine, BEFORE any ledger fold (the named invariant).
2441
+ for (const { spec, frame } of byTable(ROOM_MUTATION_OUTCOMES_TABLE)) {
2442
+ for (const op of frame.ops) {
2443
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
2444
+ if (!cells) continue; // a remove is retention pruning (mid ≤ lmid − 512), never a verdict
2445
+ const row = decodeOutcomeRow(cells);
2446
+ if (!row || row.clientId !== this.clientID) continue;
2447
+ if (spec.doc !== undefined && row.doc !== spec.doc) continue;
2448
+ const frameShape: MutationOutcomeFrame = {
2449
+ mid: row.mid,
2450
+ kind: row.kind,
2451
+ ...(row.reason !== undefined ? { reason: row.reason } : {}),
2452
+ ...(row.name !== undefined ? { name: row.name } : {}),
2453
+ ...(row.args !== undefined ? { args: row.args } : {}),
2454
+ };
2455
+ // Release-time invocation is sound here where out-of-band was REQUIRED for the socket
2456
+ // frames (`attachGate`): the socket frame races a buffered lmid ack it must beat, so it
2457
+ // may not wait behind the gate — a ROW cannot race its own release (it and the covering
2458
+ // ledger row co-committed at one cv and fold in THIS function's fixed order). The
2459
+ // machine's steps need nothing from an open release: the flip/reject only move pending
2460
+ // bookkeeping + ship an envelope, and the not-found re-invoke arm runs a fresh prediction
2461
+ // — legal before `applyRelease` opens the reconcile cycle, identical to an app invoke
2462
+ // racing the release.
2463
+ this.handleMutationOutcome(roomDomainKey(row.doc), frameShape);
2464
+ }
2465
+ }
2466
+ // (2) room-ledger rows → the daemon-carried per-domain confirm (outcomes above resolved first).
2467
+ for (const { spec, frame } of byTable(ROOM_CLIENT_MUTATIONS_TABLE)) {
2468
+ for (const op of frame.ops) {
2469
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
2470
+ if (!cells) continue; // a ledger remove confirms nothing (mirrors foldLmidOps)
2471
+ const [doc, clientId, lmid] = cells;
2472
+ if (typeof doc !== "string" || clientId !== this.clientID) continue;
2473
+ if (spec.doc !== undefined && doc !== spec.doc) continue;
2474
+ this.foldConfirm(roomDomainKey(doc), Number(lmid));
2475
+ }
2476
+ }
2477
+ // (3) watermark rows → the monotone §4.2 fence value per doc.
2478
+ for (const { spec, frame } of byTable(ROOM_WATERMARK_TABLE)) {
2479
+ for (const op of frame.ops) {
2480
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
2481
+ if (!cells) continue; // the fence is monotone — a remove never regresses it
2482
+ const [doc, flushSeq] = cells;
2483
+ const seq = Number(flushSeq);
2484
+ if (typeof doc !== "string" || !Number.isFinite(seq)) continue;
2485
+ if (spec.doc !== undefined && doc !== spec.doc) continue;
2486
+ this.roomWatermarks.set(doc, Math.max(this.roomWatermarks.get(doc) ?? 0, seq));
2487
+ }
2488
+ }
2489
+ // (4) scope-session rows → the §4.1 occupancy map (a snapshot REPLACES the scope's map — an
2490
+ // authoritative re-hydrate must drop sessions that aged out while the stream was down; a
2491
+ // batch folds add/edit/remove incrementally). Touched scopes are collected for the I-iv
2492
+ // doorbell events (a snapshot touches its minted scope even with zero ops — an emptied-out
2493
+ // scope is a legitimate 1→0 observation for the transition tracker).
2494
+ let touchedScopes: Set<string> | null = null;
2495
+ const touch = (scope: string): void => {
2496
+ (touchedScopes ??= new Set()).add(scope);
2497
+ };
2498
+ for (const { spec, frame } of byTable(SCOPE_SESSIONS_TABLE)) {
2499
+ if (frame.kind === "snapshot" && spec.scope !== undefined) {
2500
+ this.scopeSessions.set(spec.scope, new Map());
2501
+ touch(spec.scope);
2502
+ }
2503
+ for (const op of frame.ops) {
2504
+ // A remove's identity rides its (full) removed row; add/edit carry the post-image.
2505
+ const cells = op.op === "edit" ? op.new : op.row;
2506
+ const [scope, clientId, expiresAt] = cells;
2507
+ if (typeof scope !== "string" || typeof clientId !== "string") continue;
2508
+ if (spec.scope !== undefined && scope !== spec.scope) continue;
2509
+ let sessions = this.scopeSessions.get(scope);
2510
+ if (!sessions) this.scopeSessions.set(scope, (sessions = new Map()));
2511
+ if (op.op === "remove") {
2512
+ sessions.delete(clientId);
2513
+ } else {
2514
+ const exp = Number(expiresAt);
2515
+ if (Number.isFinite(exp)) sessions.set(clientId, exp);
1067
2516
  }
1068
- // A fresh session over a clientID with history: adopt the server's high-water
1069
- // mark so our next mid continues the sequence instead of colliding below it.
1070
- this.nextMid = lmid + 1;
2517
+ touch(scope);
1071
2518
  }
1072
- this.confirmedLmid = Math.max(this.confirmedLmid, lmid);
1073
2519
  }
2520
+ return touchedScopes;
2521
+ }
2522
+
2523
+ /** The I-iv occupancy count — THE one rule (§4.1/D7): unexpired (`expires_at >` the fold
2524
+ * clock's now) sessions under `scope` from OTHER clientIDs. Shared by the doorbell events
2525
+ * ({@link onGateProgress}) and the client's registration-time check (a doorbell that folded
2526
+ * BEFORE a candidate registered must still be able to trigger it) so the two can never
2527
+ * disagree. Own-clientID rows never count — a solo client cannot ring its own bell — and
2528
+ * expiry is judged on the injectable {@link FoldClock} (deterministic in a virtual-clock
2529
+ * harness, the folded-oracle discipline). */
2530
+ otherScopeSessions(scope: string): number {
2531
+ const sessions = this.scopeSessions.get(scope);
2532
+ if (!sessions) return 0;
2533
+ const now = this.clock.now();
2534
+ let n = 0;
2535
+ for (const [clientId, expiresAt] of sessions) {
2536
+ if (clientId !== this.clientID && expiresAt > now) n++;
2537
+ }
2538
+ return n;
2539
+ }
2540
+
2541
+ /** Register the I-iv doorbell event sink — see {@link ScopeSessionsEvent}. One handler (a later
2542
+ * registration replaces it, the {@link onLocalWrite} convention); client.ts is the consumer. */
2543
+ onScopeSessions(handler: (event: ScopeSessionsEvent) => void): void {
2544
+ this.scopeSessionsHandler = handler;
1074
2545
  }
1075
2546
 
1076
2547
  /** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
@@ -1079,34 +2550,53 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1079
2550
  * deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
1080
2551
  * boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
1081
2552
  * and sync never diverge (the §1.2 invariant; CRIT#2). */
1082
- private runReconcileCycle(serverDeltas: Mutation[]): void {
2553
+ private runReconcileCycle(_sourceKey: string, serverDeltas: Mutation[]): void {
2554
+ // `_sourceKey` names the authority these `deltas` confirm — `"daemon"` on the live daemon
2555
+ // path (and the GC path), a `room:doc:X` string on a room release, whose deltas already carry
2556
+ // the room's ENGINE table names (the gate's rename/filter). Kept for call-site readability
2557
+ // and tracing only: the engine itself is source-agnostic (302: one authority per table) — its
2558
+ // rewind covers EVERY tracked table and every pending mutation re-invokes below regardless of
2559
+ // which channel released, so NOTHING in this cycle may branch on it.
1083
2560
  this.local.serverBatchBegin(serverDeltas.map(toServerOp));
1084
- // The rewind cleared every optimistic write incl. prior `__agg` editsso head is now
1085
- // the server baseline. Rebuild the optimistic agg delta from scratch off the re-invoked
1086
- // (confirm-filtered) pending set, so a just-confirmed mutation's delta vanishes exactly as
1087
- // its server count is absorbed (§5 watermark no double count).
2561
+ // The rewind covers EVERY tracked table (302: the engine is source-agnosticthere is no
2562
+ // per-source rewind) including the `__agg_*` head rows whichever channel released. So the
2563
+ // optimistic agg delta rebuilds on EVERY cycle, room or daemon: reset here, re-observe from
2564
+ // the re-invoked pending set below, re-apply onto the rewound heads at the end. Gating any of
2565
+ // the three on a daemon-only cycle (the pre-302 per-source-rewind contract) would let a room
2566
+ // release wipe the optimistic `__agg` edits and skip the rebuild — every count() view snaps
2567
+ // back to the server base until the next daemon release. The delta stays sound across
2568
+ // domains: `reconcileAggHead` recomputes each head as the absolute `server_base ⊕ delta`,
2569
+ // and the server base (`this.sync`) only moves on daemon releases.
1088
2570
  this.overlay.reset();
1089
- // Re-invoke in WIRE order: assigned mids ascending, then unflushed folds (`mid == null`) last
1090
- // by creation order — the deterministic slot of FOLDED-MUTATIONS-DESIGN §4.1. A read-dependent
1091
- // mutator must replay against the same base the server computed from, which is mid order; with
1092
- // deferred fold mids, mid order creation order, so we sort rather than trust array order. The
1093
- // comparator is explicit (NOT `(mid ?? ∞) - (mid ?? ∞)`, which is `∞ - = NaN` for two unflushed
1094
- // folds a NaN comparator silently corrupts V8's sort): assigned-before-unflushed, mids
1095
- // ascending, and STABLE for two unflushed folds so they keep creation order across cycles.
2571
+ // Sort ALL pending into SEND order (the client-global `seq` ascending, then unflushed folds
2572
+ // last by creation order — the deterministic §4.1 slot; the comparator is explicit, NOT
2573
+ // `(seq ?? ∞) - (seq ?? ∞)` which is `∞ - = NaN` and corrupts V8's sort). The key MUST be
2574
+ // `seq`, never `mid`: mids are per-domain (§7.1) so mids from different domains are
2575
+ // incomparable a mid-sort would replay a room mid 1 before a daemon mid 5 that was sent
2576
+ // FIRST, letting a read-dependent mutator re-predict from a base it never saw (confirmation
2577
+ // order is per-domain; replay order is client-global). EVERY entry re-invokes the engine's
2578
+ // rewind covers every tracked table (302: there is no per-source rewind), so every entry's
2579
+ // staged writes were just un-applied, whichever channel released. Single-domain: seq order ==
2580
+ // mid order (except H-v deopt re-enqueues, which keep their ORIGINAL seq under a later daemon
2581
+ // mid — deliberately, so this very sort replays them at their original overlay position).
1096
2582
  const order = [...this.pendingMutations].sort((a, b) => {
1097
- if (a.mid === null && b.mid === null) return 0; // both unflushed → stable creation order
1098
- if (a.mid === null) return 1; // an unflushed fold sorts after every assigned mid
1099
- if (b.mid === null) return -1;
1100
- return a.mid - b.mid;
2583
+ if (a.seq === null && b.seq === null) return 0; // both unflushed → stable creation order
2584
+ if (a.seq === null) return 1; // an unflushed fold sorts after every dealt seq
2585
+ if (b.seq === null) return -1;
2586
+ return a.seq - b.seq;
1101
2587
  });
1102
2588
  const dropped = new Set<PendingMutation>();
1103
2589
  try {
1104
2590
  for (const p of order) {
1105
- const touched = new Set<string>();
2591
+ // NO `readLog` here — recording is armed only on the initial `invoke` (§3.2 #2 note on
2592
+ // `PendingMutation.reads`); a re-invocation's write-set still needs fresh capture (below).
2593
+ // The staging map follows the entry's CURRENT domain — a deopt-flipped or re-routed entry
2594
+ // re-stages onto its new domain's tables here.
2595
+ const writes: WriteSet = new Map();
1106
2596
  const ops: ChildOp[] = [];
1107
2597
  try {
1108
2598
  this.local.writeWith((tx) => {
1109
- this.runMutator(this.registry[p.name], trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), p.args);
2599
+ this.runMutator(this.registry[p.name], trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, undefined, this.stagingMap(p.domain)), p.args);
1110
2600
  });
1111
2601
  } catch {
1112
2602
  // A re-invocation threw — e.g. a read-dependent mutator whose base row the server
@@ -1122,46 +2612,52 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1122
2612
  for (const op of ops) this.overlay.observe(op);
1123
2613
  // The pending footprint is the UNION across invocations: a re-run that no-ops (touched =
1124
2614
  // {}) must NOT shrink it, else a still-pending mutation reports not-pending and its
1125
- // pending-axis clear fires early (§7.2).
1126
- for (const t of touched) p.touched.add(t);
2615
+ // pending-axis clear fires early (§7.2). `writes` mirrors this: merge, never replace.
2616
+ for (const t of writes.keys()) p.touched.add(t);
2617
+ mergeWriteSet(p.writes, writes);
1127
2618
  }
1128
2619
  // Preserve creation order in the live array (the unflushed-fold sort tiebreak depends on it).
1129
2620
  if (dropped.size) this.pendingMutations = this.pendingMutations.filter((p) => !dropped.has(p));
1130
2621
  // Re-apply the optimistic agg delta onto the (rewound) `__agg` head rows — INSIDE the open
1131
2622
  // cycle, so the writes buffer and coalesce into the one per-query delivery `serverBatchEnd`
1132
- // makes (and never escape as a separate batch).
2623
+ // makes (and never escape as a separate batch). Every cycle (see the reset above).
1133
2624
  this.reconcileAggHead();
1134
2625
  } finally {
1135
2626
  this.local.serverBatchEnd(); // ALWAYS close the cycle — ONE delivery per affected query.
1136
2627
  }
1137
2628
  }
1138
2629
 
1139
- /** The daemon restarted (a new boot id): it lost all materialization + `cv` state and its `cv`
1140
- * sequence reset, so previously-released `cv`s no longer bound the new stream. The source has
1141
- * already re-subscribed every query (reconnect → resync); drop the buffer and the `cv`
1142
- * watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
1143
- * (`onNormalized`/`onProgress` gate on `appliedCv`). Pending optimistic mutations stay put
1144
- * they re-apply on the next reconcile, and the lmid system query's fresh snapshot restores the
1145
- * confirmation watermark. */
1146
- private resetForRestart(): void {
1147
- this.buffer = [];
1148
- this.appliedCv = 0;
1149
- }
1150
-
1151
- /** The §8.5 escape: the buffer outgrew its cap (a pinned `cvMin` under churn). Drop
1152
- * everything buffered and re-register every query on the source the fresh
1153
- * snapshots arrive as ordinary frames and the next release re-hydrates via the
1154
- * footprint diff (the §5.3 path); still-pending optimism re-applies in that cycle. */
1155
- private overflow(): void {
1156
- this.buffer = [];
2630
+ /** ONE channel's authority restarted (a new boot id): it lost all materialization + `cv` state
2631
+ * and its `cv` sequence reset, so previously-released `cv`s no longer bound the new stream. The
2632
+ * source has already re-subscribed every query (reconnect → resync); drop THIS gate's buffer
2633
+ * and `cv` watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
2634
+ * (`onFrame`/`computeRelease` gate on `appliedCv`). The OTHER gates are untouched an
2635
+ * authority restart is per-channel (§5.1). Pending optimistic mutations stay put they
2636
+ * re-apply on the next reconcile, and the channel's lmid system query's fresh snapshot restores
2637
+ * its domain's confirmation watermark. */
2638
+ private resetGate(gate: SourceGate): void {
2639
+ gate.buffer = [];
2640
+ gate.appliedCv = 0;
2641
+ }
2642
+
2643
+ /** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
2644
+ * channel). Drop everything it buffered and re-register every query on that source — the fresh
2645
+ * snapshots arrive as ordinary frames and the next release re-hydrates via the footprint diff
2646
+ * (the §5.3 path); still-pending optimism re-applies in that cycle. The other gates' buffers
2647
+ * and subscriptions are untouched. */
2648
+ private overflow(gate: SourceGate): void {
2649
+ gate.buffer = [];
1157
2650
  for (const sub of this.remoteSubs.values()) {
1158
- this.source.unregisterQuery(sub.sourceQid);
1159
- this.source.registerQuery(sub.sourceQid, sub.remote);
2651
+ // Re-register only the subs THIS channel owns ({@link RemoteSub.channel} — the one source
2652
+ // of truth, G-iii): resubscribing another channel's sub here would fork its stream.
2653
+ if (sub.channel !== gate.key) continue;
2654
+ gate.source.unregisterQuery(sub.sourceQid);
2655
+ gate.source.registerQuery(sub.sourceQid, sub.remote);
1160
2656
  }
1161
2657
  // The lmid system query's buffered frames were dropped too — re-subscribe it so a
1162
2658
  // fresh snapshot restores the confirmation watermark.
1163
- this.source.unregisterQuery(LMID_QID);
1164
- this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
2659
+ gate.source.unregisterQuery(LMID_QID);
2660
+ gate.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
1165
2661
  }
1166
2662
 
1167
2663
  private setResultType(qid: QueryId, rt: ResultType): void {
@@ -1177,29 +2673,47 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1177
2673
  }
1178
2674
 
1179
2675
  /** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
1180
- * lift those views out of `unknown` (loading). Idempotent a re-hydrate snapshot re-marks
1181
- * harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
2676
+ * lift those views out of `unknown` (loading). A ROOM sub's hydration additionally queues the
2677
+ * 302 §4.1 swap-in performed at the applyRelease TAIL ({@link processSwapIns}), once the
2678
+ * reconcile has folded this snapshot into the room tables. Idempotent — a re-hydrate snapshot
2679
+ * re-marks harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
1182
2680
  private markSubHydrated(sourceQid: QueryId): void {
1183
2681
  const key = this.sourceToRemote.get(sourceQid);
1184
2682
  if (!key) return;
1185
2683
  const sub = this.remoteSubs.get(key);
1186
2684
  if (!sub || sub.hydrated) return;
1187
2685
  sub.hydrated = true;
2686
+ if (sub.channel !== "daemon" && !this.systemQids.has(sub.sourceQid)) this.pendingSwapIns.add(sub);
1188
2687
  for (const localQid of sub.localQids.keys()) {
1189
2688
  this.hydrated.add(localQid);
1190
2689
  this.recomputeResultType(localQid);
1191
2690
  }
1192
2691
  }
1193
2692
 
1194
- private retainRemote(retainQid: QueryId, remote: RemoteQuery, localQueryId: QueryId | undefined = retainQid): void {
2693
+ /** `channel` (G-iii registration-time routing): the gate the sub registers on the qid's
2694
+ * ownership is fixed HERE, at retain time (no lazy claim; `onFrame` only asserts it). Default
2695
+ * `"daemon"`, so every channel-less caller is byte-identical to before. */
2696
+ private retainRemote(
2697
+ retainQid: QueryId,
2698
+ remote: RemoteQuery,
2699
+ localQueryId: QueryId | undefined = retainQid,
2700
+ channel = "daemon",
2701
+ ): void {
2702
+ const gate = this.requireGate(channel); // throw loudly BEFORE any sub state moves
1195
2703
  const key = remoteKey(remote);
1196
2704
  let sub = this.remoteSubs.get(key);
1197
2705
  let isNew = false;
1198
2706
  if (!sub) {
1199
- sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false };
2707
+ sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false, channel };
1200
2708
  this.remoteSubs.set(key, sub);
1201
2709
  this.sourceToRemote.set(sub.sourceQid, key);
1202
2710
  isNew = true;
2711
+ } else if (sub.channel !== channel) {
2712
+ // A (name,args) sub lives on ONE channel — a second retain naming another is a wiring bug
2713
+ // (it would split the query's frames across two cv timelines). Fail loudly.
2714
+ throw new Error(
2715
+ `optimistic backend: query "${remote.name}" is already retained on channel ${JSON.stringify(sub.channel)} — cannot retain it on ${JSON.stringify(channel)}`,
2716
+ );
1203
2717
  }
1204
2718
  sub.refCount++;
1205
2719
  if (localQueryId !== undefined) {
@@ -1207,13 +2721,37 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1207
2721
  // A late-joiner to an already-hydrated sub is immediately hydrated; otherwise this view now
1208
2722
  // awaits the sub's first snapshot (so a split-path local view registered `complete` flips to
1209
2723
  // `unknown` here). Then recompute its lifecycle.
1210
- if (sub.hydrated) this.hydrated.add(localQueryId);
1211
- else this.hydrated.delete(localQueryId);
1212
- this.recomputeResultType(localQueryId);
2724
+ if (sub.hydrated) {
2725
+ this.hydrated.add(localQueryId);
2726
+ // 302 §4.1 LATE JOIN: a ROOM sub's one-shot swap queue ({@link markSubHydrated}) fired at
2727
+ // its first released snapshot — long gone by now — so a view attaching afterwards must
2728
+ // swap onto the room's namespaced tables HERE, or its engine query stays registered on
2729
+ // the plain daemon tables the room channel never feeds (empty/stale, reported complete,
2730
+ // diverging from its already-swapped siblings forever). The room tables already hold the
2731
+ // released state (hydrated ⇒ folded), so swapping immediately is the ordinary
2732
+ // after-the-data order; processSwapIns skips already-swapped siblings, and
2733
+ // pendingSwapIns is empty outside a release, so exactly this sub's un-swapped views move.
2734
+ if (sub.channel !== "daemon" && !this.systemQids.has(sub.sourceQid)) {
2735
+ this.pendingSwapIns.add(sub);
2736
+ this.processSwapIns();
2737
+ }
2738
+ // FORCE the notify past setResultType's dedup: the labeled split registers the local
2739
+ // half `complete`, then flips the STORE view to `unknown` for the lease window WITHOUT
2740
+ // touching our record — so a complete→complete recompute here would swallow the event
2741
+ // and strand the late-joining view `unknown` forever. Redundant notifies are idempotent
2742
+ // Store-side; a swallowed transition is not recoverable.
2743
+ this.resultTypes.set(localQueryId, "complete");
2744
+ this.resultTypeHandler(localQueryId, "complete");
2745
+ } else {
2746
+ this.hydrated.delete(localQueryId);
2747
+ this.recomputeResultType(localQueryId);
2748
+ }
1213
2749
  }
1214
2750
  this.localToRemote.set(retainQid, key);
1215
2751
  this.remoteRetainToLocal.set(retainQid, localQueryId);
1216
- if (isNew) this.source.registerQuery(sub.sourceQid, remote);
2752
+ // Register on the CHANNEL's source (G-iii): the qid's frames will arrive — and buffer, release,
2753
+ // and overflow — on that channel's own §5.1 gate.
2754
+ if (isNew) gate.source.registerQuery(sub.sourceQid, remote);
1217
2755
  }
1218
2756
 
1219
2757
  private releaseRemote(retainQid: QueryId): QueryId | undefined {
@@ -1231,7 +2769,10 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1231
2769
  else sub.localQids.delete(localQueryId);
1232
2770
  }
1233
2771
  if (sub.refCount > 0) return undefined;
1234
- this.source.unregisterQuery(sub.sourceQid);
2772
+ // Unregister from the SAME gate's source the retain registered on. Since I-v a gate CAN be
2773
+ // removed ({@link disconnectSource}) — but never with a live sub on it ({@link
2774
+ // demoteRoomSource} validates loudly), so the daemon fallback is purely defensive.
2775
+ (this.gates.get(sub.channel) ?? this.daemonGate).source.unregisterQuery(sub.sourceQid);
1235
2776
  this.sourceToRemote.delete(sub.sourceQid);
1236
2777
  this.remoteSubs.delete(key);
1237
2778
  return sub.sourceQid;
@@ -1266,6 +2807,11 @@ interface RemoteSub {
1266
2807
  localQids: Map<QueryId, number>;
1267
2808
  /** Whether this sub's first server snapshot has been released (drives hydration of its views). */
1268
2809
  hydrated: boolean;
2810
+ /** The authority channel (gate/source key) this sub registered on — fixed at retain time
2811
+ * (G-iii registration-time routing), `"daemon"` unless a channel-keyed retain named another.
2812
+ * The ONE source of truth for qid→channel ownership: `onFrame`'s wrong-channel assertion and
2813
+ * the per-gate `overflow` both read it (via {@link OptimisticBackend.channelOf} / directly). */
2814
+ channel: string;
1269
2815
  }
1270
2816
 
1271
2817
  function remoteKey(remote: RemoteQuery): string {
@@ -1325,18 +2871,90 @@ function colIndexFromSchema<S extends ColsMap>(schema: Schema<S>): Record<string
1325
2871
  return out;
1326
2872
  }
1327
2873
 
1328
- /** Wrap the raw wasm txn as the client `MutationTx`, recording the touched tables (the client
1329
- * knows its own footprint what the pending axis derives from, §7.2). The keyed methods validate
1330
- * column names eagerly: a typo'd table or column throws with the valid names listed, at the moment
1331
- * the mutator runs.
2874
+ /** Merge a fresh invocation's write-set into a `PendingMutation`'s accumulated one (§3.2 #1, rebase
2875
+ * re-invocation): each pk's value is OVERWRITTEN with the newest image (a later invocation ran
2876
+ * against the base the rebase just replaced, so its view supersedes the earlier one), but a key
2877
+ * present only in `dest` is left alone — the same union-never-shrink rule `touched` already
2878
+ * follows (§7.2: "a re-run that no-ops must NOT shrink it"). */
2879
+ function mergeWriteSet(dest: WriteSet, src: WriteSet): void {
2880
+ for (const [table, byPk] of src) {
2881
+ let d = dest.get(table);
2882
+ if (!d) dest.set(table, (d = new Map()));
2883
+ for (const [pkKey, rec] of byPk) d.set(pkKey, rec);
2884
+ }
2885
+ }
2886
+
2887
+ // --- the 302 room-table helpers -------------------------------------------------------
2888
+
2889
+ /** The namespaced ENGINE table backing wire `table` for room `sourceKey` (302 §2: `room_deck` ≠
2890
+ * `deck` — one authority per table). `@` appears in no schema table name — ENFORCED by
2891
+ * `createSchema`/`extendSchema`'s addTableMeta ban (packages/client/src/schema.ts), so the name
2892
+ * cannot collide with a real table. */
2893
+ export function roomEngineTable(table: string, sourceKey: string): string {
2894
+ return `${table}@${sourceKey}`;
2895
+ }
2896
+
2897
+ /** Rename a room gate's released deltas into the room's namespaced tables, DROPPING deltas for
2898
+ * wire tables outside the map (context / unknown — the daemon is their sole authority, 302 §6).
2899
+ * Identity (no copy) for a map-less gate — the daemon path is untouched. */
2900
+ function mapGateDeltas(gate: SourceGate, muts: Mutation[]): Mutation[] {
2901
+ const map = gate.tableMap;
2902
+ if (map === undefined) return muts;
2903
+ const out: Mutation[] = [];
2904
+ for (const m of muts) {
2905
+ const engineTable = map.get(m.table);
2906
+ if (engineTable === undefined) continue;
2907
+ out.push({ ...m, table: engineTable });
2908
+ }
2909
+ return out;
2910
+ }
2911
+
2912
+ /** Rename every TABLE reference in a query AST through `map` (302 §2 point 3 — the room-homed
2913
+ * view's rewrite): the root `table`, every `related` subquery, every `correlatedSubquery`
2914
+ * (EXISTS) condition — walking the KNOWN wire-AST shape, never a blind key scan: `start.row` is
2915
+ * keyed by COLUMN name (a schema column literally named `table` must keep its bound value), and
2916
+ * the same goes for any future column-keyed record. Tables absent from the map keep their name —
2917
+ * that is the client-side join across kinds (a room table joined to daemon-owned context,
2918
+ * 201-style). Structural clone; the input AST is never mutated. */
2919
+ export function remapAstTables(ast: Ast, map: ReadonlyMap<string, string>): Ast {
2920
+ const walkCond = (c: Condition): Condition => {
2921
+ if (c.type === "and" || c.type === "or") return { ...c, conditions: c.conditions.map(walkCond) };
2922
+ if (c.type === "correlatedSubquery") return { ...c, related: walkSub(c.related) };
2923
+ return c; // "simple" — column refs and literals carry no table reference
2924
+ };
2925
+ const walkSub = (s: CorrelatedSubquery): CorrelatedSubquery => ({ ...s, subquery: walk(s.subquery) });
2926
+ const walk = (a: Ast): Ast => ({
2927
+ ...a,
2928
+ table: map.get(a.table) ?? a.table,
2929
+ ...(a.where !== undefined ? { where: walkCond(a.where) } : {}),
2930
+ ...(a.having !== undefined ? { having: walkCond(a.having) } : {}),
2931
+ ...(a.related !== undefined ? { related: a.related.map(walkSub) } : {}),
2932
+ });
2933
+ return walk(ast);
2934
+ }
2935
+
2936
+ /** Wrap the raw wasm txn as the client `MutationTx`, capturing a pk-granular write-set as it
2937
+ * applies (`writes`, a {@link WriteSet} — table → pk-key → last-write-wins image, §3.2 #1);
2938
+ * `touched` (the pending axis's table-granular Set, §7.2) is derived by the CALLER as
2939
+ * `new Set(writes.keys())`, never populated here. The keyed methods validate column names eagerly:
2940
+ * a typo'd table or column throws with the valid names listed, at the moment the mutator runs.
2941
+ *
2942
+ * With `trapReads` (the FOLDED path, §5), the PUBLIC reads `tx.get`/`tx.row`/`tx.query` throw
2943
+ * `FoldReadError` — a folded mutator that reads state to compute its write is non-absorbing and
2944
+ * refused. The keyed writers (`update`/`upsert`/`insertIgnore`/`delete`) still read internally to
2945
+ * preserve unspecified columns / check pre-existence; that is fold-legal (the trap wraps only the
2946
+ * returned object's `get`/`row`/`query` surface, never the writers' internal probe) — unchanged
2947
+ * by H-ii, which records those probes but arms recording only where the trap never is.
1332
2948
  *
1333
- * With `trapReads` (the FOLDED path, §5), the PUBLIC reads `tx.get`/`tx.row` throw `FoldReadError`
1334
- * a folded mutator that reads state to compute its write is non-absorbing and refused. The keyed
1335
- * writers (`update`/`upsert`/`delete`) still read internally to preserve unspecified columns; that
1336
- * is column-preservation, not value-derivation, so it stays allowed. */
2949
+ * With `readLog` (recording mode, RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3.2 #2) a SIBLING
2950
+ * of `trapReads`, the two never armed together by any call site the PUBLIC `tx.get`/`tx.row`
2951
+ * push a `(table, pk, outcome, source?)` {@link ReadRecord} (per-read provenance via the
2952
+ * `provenance` probe, H-ii §3.2 #3), `tx.query` pushes its resolved AST, and (H-ii) the keyed
2953
+ * writers' pre-existence probes record through the same path. Pure capture: it changes no return
2954
+ * value, throws nothing, and is a no-op when `readLog` is omitted. */
1337
2955
  /** Apply one logical {@link MutationOp} (yielded by a shared generator mutator) onto the client's
1338
2956
  * keyed {@link MutationTx} — the same methods a plain client mutator calls directly. Column
1339
- * validation, touched-table tracking, and op collection all happen inside those methods. */
2957
+ * validation, write-set capture, and op collection all happen inside those methods. */
1340
2958
  function applyOpToTx(tx: MutationTx, op: MutationOp): void {
1341
2959
  switch (op.kind) {
1342
2960
  case "insert":
@@ -1354,17 +2972,27 @@ function applyOpToTx(tx: MutationTx, op: MutationOp): void {
1354
2972
 
1355
2973
  function trackingTx(
1356
2974
  tx: WasmWriteTxn,
1357
- touched: Set<string>,
2975
+ writes: WriteSet,
1358
2976
  specs: TableSpecs,
1359
2977
  localTables: Set<string>,
1360
2978
  onOp?: (op: ChildOp) => void,
1361
2979
  trapReads = false,
2980
+ readLog?: ReadLog,
2981
+ /** The 302 staging map for a room-DECLARED mutation: wire table → the room's namespaced engine
2982
+ * table for the tables the room owns; identity for everything else. Every raw engine access —
2983
+ * reads and writes — goes through it, so a room mutator reads/writes the room's own state
2984
+ * (its optimistic effects land where the room-homed views look) while its envelope still
2985
+ * ships the wire names. Absent (or a non-owned table) ⇒ the plain table, verbatim. */
2986
+ stage?: ReadonlyMap<string, string>,
1362
2987
  ): MutationTx {
1363
2988
  const spec = (table: string) => {
1364
2989
  const s = specs[table];
1365
2990
  if (!s) throw new Error(`unknown table ${JSON.stringify(table)} — tables: ${Object.keys(specs).join(", ")}`);
1366
2991
  return s;
1367
2992
  };
2993
+ /** The ENGINE table a wire-named access lands on (302 §2). Schema/column validation always
2994
+ * runs on the WIRE name (the namespaced twin shares the spec). */
2995
+ const staged = (table: string): string => stage?.get(table) ?? table;
1368
2996
 
1369
2997
  // M1 (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6): a replayable mutator is a pure function of
1370
2998
  // (synced base + args) — it neither READS nor WRITES a local-only table. The server runs the
@@ -1401,6 +3029,26 @@ function trackingTx(
1401
3029
  const pkCells = (table: string, obj: KeyedRow): WireValue[] =>
1402
3030
  spec(table).primaryKey.map((i) => obj[spec(table).columns[i]]);
1403
3031
 
3032
+ // pk from the POSITIONAL wire shape (raw cells in schema column order) — the counterpart of
3033
+ // `pkCells` (which reads a KeyedRow) for the raw `add`/`remove`/`edit` writers below (§3.2 #1).
3034
+ const pkFromCells = (table: string, cells: WireValue[]): WireValue[] =>
3035
+ spec(table).primaryKey.map((i) => cells[i]);
3036
+
3037
+ // Record (or overwrite) this pk's write for the invocation (§3.2 #1): last-write-wins WITHIN
3038
+ // this invocation — an add-then-edit (or edit-then-edit) of the same pk collapses to its final
3039
+ // image, matching the engine head's own semantics for that pk. The record is replaced with
3040
+ // exactly the arguments given: the CALLERS (`edit`/`remove` below, consulting `prior`) decide
3041
+ // the pre-image per the H-ii coalescing matrix on {@link WriteRecord}. Keyed by the STAGED
3042
+ // (engine) table name, so the pending axis and the write-set match what the engine holds.
3043
+ const recordWrite = (engineTable: string, pk: WireValue[], row: WireValue[] | undefined, oldRow?: WireValue[]): void => {
3044
+ let byPk = writes.get(engineTable);
3045
+ if (!byPk) writes.set(engineTable, (byPk = new Map()));
3046
+ const pkKey = stableJson(pk);
3047
+ // Defensive copies: the wasm binding's returned arrays are not contractually immutable/unique,
3048
+ // so a captured record must not alias a cell array the engine could later reuse or mutate.
3049
+ byPk.set(pkKey, { table: engineTable, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
3050
+ };
3051
+
1404
3052
  // A full insert row: each cell is `obj[c]`, or `null` for an omitted nullable column (design 206
1405
3053
  // §6.2); a `json` object is stringified for the engine (`toCell`). Non-nullable columns are
1406
3054
  // guaranteed present by `checkColumns(full)`.
@@ -1415,29 +3063,104 @@ function trackingTx(
1415
3063
  return out;
1416
3064
  };
1417
3065
 
1418
- // Internal read used by the keyed writers below and the keyed `row` reader; never trapped (for
1419
- // the FOLD trap) but ALWAYS guarded against local reads (M1).
3066
+ // The raw, UN-recorded read primitive behind `getImpl`/`rowImpl` the M1 local guard + the txn
3067
+ // read, nothing else. Slice B deliberately kept the keyed writers (`update`/`upsert`/
3068
+ // `insertIgnore`/`delete`) on this, un-recorded ("recording is about the PUBLIC read entry
3069
+ // points"). H-ii deliberately REVERSES that: the pre-existence probe each keyed writer BRANCHES
3070
+ // on is genuine value-dependence the §3 routing proof must see — the concrete silent-drop shape
3071
+ // is `update("cards", {id:5,…})` where the client's daemon slice has the row but the room's
3072
+ // footprint lacks it: the room-side update no-ops, the commit "succeeds" with zero effects, the
3073
+ // confirm retires the entry, and the user's edit silently vanishes. The proof can only refuse
3074
+ // that route if the probe is on the record. So the keyed writers now probe through `getImpl`
3075
+ // (recorded like any public read, §3.2 #3); the FOLD trap is unaffected — it wraps only the
3076
+ // returned object's `get`/`row`/`query` surface, so keyed writers stay fold-legal and the
3077
+ // trapped path (where `readLog` is never armed) records nothing, exactly as before.
1420
3078
  const rawGet = (table: string, pk: WireValue[]) => {
1421
3079
  assertNotLocal(table, "read");
1422
- return tx.get(table, pk) as WireValue[] | undefined;
3080
+ return tx.get(staged(table), pk) as WireValue[] | undefined;
3081
+ };
3082
+
3083
+ // Push one {@link ReadRecord} when recording is armed (§3.2 #2/#3): outcome from `row`'s
3084
+ // presence. Pure capture for inspection.
3085
+ const recordRead = (table: string, pk: WireValue[], row: WireValue[] | undefined): void => {
3086
+ if (!readLog) return;
3087
+ readLog.reads.push({
3088
+ table,
3089
+ pk: [...pk],
3090
+ outcome: row === undefined ? "absent" : "present",
3091
+ });
1423
3092
  };
3093
+
3094
+ // The PUBLIC positional read (§3.2 #2) — and, since H-ii, the keyed writers' pre-existence
3095
+ // probe (§3.2 #3, see the `rawGet` note): `rawGet` plus a `readLog` record when recording is
3096
+ // armed. A no-op record when `readLog` is omitted — exactly `rawGet`'s behavior then.
3097
+ const getImpl = (table: string, pk: WireValue[]): WireValue[] | undefined => {
3098
+ const result = rawGet(table, pk);
3099
+ recordRead(table, pk, result);
3100
+ return result;
3101
+ };
3102
+
3103
+ // The PUBLIC keyed read (§3.2 #2), the `row` counterpart of `getImpl`.
3104
+ const rowImpl = (table: string, pk: KeyedRow): KeyedRow | undefined => {
3105
+ checkColumns(table, pk, false);
3106
+ const pkc = pkCells(table, pk);
3107
+ const cells = rawGet(table, pkc);
3108
+ recordRead(table, pkc, cells);
3109
+ return cells ? toKeyed(table, cells) : undefined;
3110
+ };
3111
+
3112
+ // The pk's existing record from THIS invocation, if any — the coalescing-matrix input for
3113
+ // `edit`/`remove` below (see {@link WriteRecord}). Keyed by the STAGED name like the records.
3114
+ const prior = (table: string, pk: WireValue[]): WriteRecord | undefined =>
3115
+ writes.get(staged(table))?.get(stableJson(pk));
1424
3116
  const add = (table: string, row: WireValue[]) => {
1425
3117
  assertNotLocal(table, "write");
1426
- touched.add(table);
3118
+ const t = staged(table);
3119
+ recordWrite(t, pkFromCells(table, row), row);
3120
+ // ChildOps carry the WIRE name (unlike the write-set): the agg overlay's defs are keyed by
3121
+ // the ORIGINAL AST's child tables (`collectAggDefs`), and the `__agg_*` heads it feeds are
3122
+ // shared by plain and swapped views alike — a staged name would silently miss the dispatch
3123
+ // and the optimistic count would lag every room-declared write until its echo.
1427
3124
  onOp?.({ table, kind: "add", row });
1428
- tx.add(table, row);
3125
+ tx.add(t, row);
1429
3126
  };
1430
3127
  const remove = (table: string, row: WireValue[]) => {
1431
3128
  assertNotLocal(table, "write");
1432
- touched.add(table);
1433
- onOp?.({ table, kind: "remove", row });
1434
- tx.remove(table, row);
3129
+ const t = staged(table);
3130
+ const pk = pkFromCells(table, row);
3131
+ // The remove PRE-IMAGE (the H-ii matrix on {@link WriteRecord}): remove-after-edit/-remove
3132
+ // keeps the ORIGINAL captured pre-image (the txn-entry base — the net effect is a remove of
3133
+ // the row the external world last knew, never the edited transient). Otherwise (first touch,
3134
+ // or remove-after-add) the truthful full-width row is the txn-visible one — `tx.get` read
3135
+ // BEFORE the remove stages (read-your-writes: an add of this pk earlier in the SAME
3136
+ // invocation shows through). Falls back to the caller's asserted `row` when the pk is not
3137
+ // resident (a raw remove of an absent row) — a captured remove thus always carries a
3138
+ // full-width pre-image.
3139
+ const oldRow = prior(table, pk)?.oldRow ?? (tx.get(t, pk) as WireValue[] | undefined) ?? row;
3140
+ recordWrite(t, pk, undefined, oldRow);
3141
+ onOp?.({ table, kind: "remove", row }); // wire name — see `add`
3142
+ tx.remove(t, row);
1435
3143
  };
1436
3144
  const edit = (table: string, oldRow: WireValue[], newRow: WireValue[]) => {
1437
3145
  assertNotLocal(table, "write");
1438
- touched.add(table);
1439
- onOp?.({ table, kind: "edit", row: newRow, old: oldRow });
1440
- tx.edit(table, oldRow, newRow);
3146
+ const t = staged(table);
3147
+ const pk = pkFromCells(table, newRow);
3148
+ // The edit PRE-IMAGE (the H-ii matrix on {@link WriteRecord}). First touch: the txn-visible
3149
+ // row read BEFORE staging, falling back to the caller's asserted `oldRow` when the pk is not
3150
+ // resident (covers the pk-MOVING raw edit — the record is keyed by the NEW pk; the pre-image
3151
+ // carries the OLD row). Edit-after-edit: keep the FIRST pre-image (the txn-entry base).
3152
+ // Edit-after-add / edit-after-remove: the record collapses to a (re-)insert — NO pre-image
3153
+ // (the pk did not pre-exist this invocation's base).
3154
+ const p = prior(table, pk);
3155
+ const pre =
3156
+ p === undefined
3157
+ ? ((tx.get(t, pk) as WireValue[] | undefined) ?? oldRow)
3158
+ : p.row !== undefined && p.oldRow !== undefined
3159
+ ? p.oldRow
3160
+ : undefined;
3161
+ recordWrite(t, pk, newRow, pre);
3162
+ onOp?.({ table, kind: "edit", row: newRow, old: oldRow }); // wire name — see `add`
3163
+ tx.edit(t, oldRow, newRow);
1441
3164
  };
1442
3165
 
1443
3166
  // The folded read trap (§5): a mutator that reads to compute its write is refused. `() => never`
@@ -1454,29 +3177,30 @@ function trackingTx(
1454
3177
  const runQuery = (q: QueryArg): QueryResultRow[] => {
1455
3178
  const ast = q.ast();
1456
3179
  for (const t of collectTables(ast)) assertNotLocal(t, "read");
1457
- return tx.query(ast) as QueryResultRow[];
3180
+ readLog?.queries.push(ast);
3181
+ // A room-declared mutator's one-shot query reads the room's own staged state for the tables
3182
+ // the room owns (the same staging rule as the point reads above).
3183
+ return tx.query(stage !== undefined && stage.size > 0 ? remapAstTables(ast, stage) : ast) as QueryResultRow[];
1458
3184
  };
1459
3185
 
1460
3186
  return {
1461
- get: trapReads ? trapped : rawGet,
3187
+ get: trapReads ? trapped : getImpl,
1462
3188
  query: trapReads ? trapped : runQuery,
1463
3189
  add,
1464
3190
  remove,
1465
3191
  edit,
1466
- row: trapReads
1467
- ? trapped
1468
- : (table, pk) => {
1469
- checkColumns(table, pk, false);
1470
- const cells = rawGet(table, pkCells(table, pk));
1471
- return cells ? toKeyed(table, cells) : undefined;
1472
- },
3192
+ row: trapReads ? trapped : rowImpl,
1473
3193
  insert: (table, row) => {
1474
3194
  checkColumns(table, row, true);
1475
3195
  add(table, toCells(table, row));
1476
3196
  },
3197
+ // The keyed writers' pre-existence probes go through `getImpl` — RECORDED reads since H-ii
3198
+ // (§3.2 #3): each writer BRANCHES on the probe, a value-dependence the routing proof must see
3199
+ // (the silent-drop rationale on `rawGet` above). Fold-legal exactly as before (the trap wraps
3200
+ // the public surface above, never these), and byte-identical when recording is off.
1477
3201
  update: (table, row) => {
1478
3202
  checkColumns(table, row, false);
1479
- const current = rawGet(table, pkCells(table, row));
3203
+ const current = getImpl(table, pkCells(table, row));
1480
3204
  if (!current) return; // rebase-friendly: the row may have vanished upstream
1481
3205
  const s = spec(table);
1482
3206
  // Named columns overwrite (a `json` object stringified via `toCell`); unnamed keep `current`,
@@ -1486,17 +3210,17 @@ function trackingTx(
1486
3210
  },
1487
3211
  upsert: (table, row) => {
1488
3212
  checkColumns(table, row, true);
1489
- const current = rawGet(table, pkCells(table, row));
3213
+ const current = getImpl(table, pkCells(table, row));
1490
3214
  if (current) edit(table, current, toCells(table, row));
1491
3215
  else add(table, toCells(table, row));
1492
3216
  },
1493
3217
  insertIgnore: (table, row) => {
1494
3218
  checkColumns(table, row, true);
1495
- if (!rawGet(table, pkCells(table, row))) add(table, toCells(table, row));
3219
+ if (!getImpl(table, pkCells(table, row))) add(table, toCells(table, row));
1496
3220
  },
1497
3221
  delete: (table, pk) => {
1498
3222
  checkColumns(table, pk, false);
1499
- const current = rawGet(table, pkCells(table, pk));
3223
+ const current = getImpl(table, pkCells(table, pk));
1500
3224
  if (!current) return; // rebase-friendly no-op
1501
3225
  remove(table, current);
1502
3226
  },
@@ -1530,4 +3254,3 @@ function intersects(a: Set<string>, b: Set<string>): boolean {
1530
3254
  for (const x of b) if (a.has(x)) return true;
1531
3255
  return false;
1532
3256
  }
1533
-