@rindle/optimistic 0.5.0 → 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
@@ -60,6 +60,7 @@ import type {
60
60
  ColsMap,
61
61
  ColType,
62
62
  Condition,
63
+ CorrelatedSubquery,
63
64
  IsoTx,
64
65
  KeyedRow,
65
66
  Mutation,
@@ -82,7 +83,7 @@ import type {
82
83
  WireValue,
83
84
  } from "@rindle/client";
84
85
  import { aggTableSchemas, NormalizedSync, rewriteAggregates, type ColCounts, type PkCols } from "@rindle/normalized";
85
- import { WasmBackend, type ServerDeltaOp, type WasmWriteTxn, type WritableDescriptor } from "@rindle/wasm";
86
+ import { WasmBackend, type ServerDeltaOp, type WasmWriteTxn } from "@rindle/wasm";
86
87
 
87
88
  import { AggOverlay, type ChildOp, collectAggDefs } from "./agg-overlay.ts";
88
89
  import {
@@ -170,16 +171,13 @@ const LMID_QID: QueryId = 0;
170
171
  * the post-write image (positional wire cells, schema column order); `undefined` for a `remove` —
171
172
  * no row survives it, and `row === undefined` stays the remove marker.
172
173
  *
173
- * `oldRow` is the PRE-IMAGE, captured for a `remove` (G-iii, §7.3 tombstone) AND since H-ii
174
- * an `edit` (it feeds H-iii's join-key no-change rule, old vs new join-key cells, and mirrors the
175
- * engine, which routes an Edit by its OLD rowthe H-i handoff): the full-width row read via
176
- * `tx.get` immediately BEFORE the write staged read-your-writes, so a write to the same pk
177
- * earlier in the SAME invocation shows through falling back to the caller's asserted old row
178
- * when the pk is not txn-visible (a raw remove/edit of an absent row; incl. the pk-MOVING raw
179
- * edit, whose record is keyed by the NEW pk yet whose pre-image is the caller's OLD row — the row
180
- * H-i's `provenanceOf` routes by). A captured remove/edit thus always carries a full-width
181
- * pre-image (the engine width-checks `holdBackAbsent`; Slice H evaluates writable predicates on
182
- * it) — with ONE exception: a record that collapses to a (re-)insert has none, see the matrix.
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 throughfalling 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.
183
181
  *
184
182
  * Coalescing within one invocation is last-write-wins per pk on `row` (the final image matches
185
183
  * the engine head's own semantics for that pk) with `oldRow` pinned to the TXN-ENTRY BASE — the
@@ -227,18 +225,6 @@ export interface ReadRecord {
227
225
  table: string;
228
226
  pk: WireValue[];
229
227
  outcome: ReadOutcome;
230
- /** Per-read provenance (H-ii, §3.2 #3): the engine's `provenanceOf` answer AT THE MOMENT OF THE
231
- * READ — which source ("daemon" / a room key) served the row the mutator saw. Live pre-commit
232
- * state: an open txn's staged writes are not consulted (H-i), so a PRESENT read of a pk this
233
- * txn itself created reports `undefined` — recorded honestly, key omitted (the router treats
234
- * self-reads via write-set membership, not provenance). ABSENT reads carry no source (the
235
- * mutator saw no row; there is no full-width row to probe with — a pk this txn staged-removed
236
- * thus also records `undefined`, again a write-set-membership case for the router). Probed only
237
- * on a PROMOTED table ({@link promotedTables}): a never-promoted (Collapsed) table is
238
- * daemon-owned by construction, so a pure single-domain app pays zero wasm calls per read and
239
- * `undefined` is still correct (everything is daemon-owned). One probe call per recorded
240
- * present read, at read time. */
241
- source?: string;
242
228
  }
243
229
 
244
230
  /** The read-log captured over ONE mutator invocation when recording is armed (RINDLE-REALTIME-
@@ -246,106 +232,14 @@ export interface ReadRecord {
246
232
  * and, since H-ii, the keyed writers' internal pre-existence probes) plus every resolved query AST
247
233
  * (`queries`, from `tx.query`). A SIBLING of the folded read TRAP (`FoldReadError` below) — the
248
234
  * trap arms on the folded path and throws before any read completes (recording never runs there);
249
- * recording arms on the ordinary (non-folded) prediction run and never throws. Pure capture: which
250
- * row/predicate a read is actually PROVEN covered by (§3.1's table) is a later slice's job. */
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). */
251
238
  export interface ReadLog {
252
239
  reads: ReadRecord[];
253
240
  queries: Ast[];
254
241
  }
255
242
 
256
- /** One promoted `(sourceKey, table)`'s client-held routing spec — THE per-table routing record the
257
- * §3 prove-or-slow-path router reads (H-iii). Recorded by {@link OptimisticBackend.promoteRoomTable}
258
- * in the same breath as the engine attach, from the lease's `RealtimeLeaseTableSpec` (the
259
- * api-server's `RoomTableSpec`, one compiler with the boot wire).
260
- *
261
- * - `writable`: whether the room may write this table AT ALL — `true` iff the registered engine
262
- * {@link WritableDescriptor} is not `none` (a context/"followed" table is `none`; the daemon
263
- * stays write-authoritative for it, §2.2). Rule #1 of the write proof.
264
- * - `joinKeyCols`: the correlation columns the footprint binds on this table — the "room mutators
265
- * never change join keys" input (an edit with a moved join-key cell would re-parent the row
266
- * across the footprint's correlation and is refused room-routing). Deliberately client-side —
267
- * they never cross the wasm {@link WritableDescriptor} ABI (the room GATE enforces them
268
- * engine-side, H-iv).
269
- * - `where`: the lease's row-local writable predicate (advisory here — the router evaluates the
270
- * ENGINE's compiled copy via `writableMatches`, never this one; kept for introspection parity
271
- * with the wire).
272
- * - `footprintWhere`: the EXACT footprint-membership predicate (H-iv-b: root-node-only, lossless
273
- * extraction; the vacuous-true `{type:"and",conditions:[]}` for an exact unconstrained root;
274
- * ABSENT for child/correlated tables). The read proof's pk-membership test input — see
275
- * {@link OptimisticBackend.deriveDomain}. */
276
- export interface RoomTableRouting {
277
- writable: boolean;
278
- joinKeyCols: string[];
279
- where?: Condition;
280
- footprintWhere?: Condition;
281
- }
282
-
283
- /** The per-table spec {@link OptimisticBackend.promoteRoomTable} receives beside the engine
284
- * descriptor (H-iii spec plumbing) — the wire-facing half of {@link RoomTableRouting}
285
- * (`writable` is derived from the descriptor's kind, never passed separately). */
286
- export interface RoomTableRoutingSpec {
287
- joinKeyCols?: readonly string[];
288
- where?: Condition;
289
- footprintWhere?: Condition;
290
- }
291
-
292
- /** The §3 router's Q6 measurement hook ({@link OptimisticBackend.__inspectDomains}`().routing`):
293
- * how often derivation succeeded (per derived room) and why it fell back to the daemon (per
294
- * failure reason). `reasons` counts PER-CANDIDATE proof failures plus the terminal reasons
295
- * (`no-candidates` / `tx-query` / `ambiguous`) — with multiple candidate rooms one derivation can
296
- * bump several reason counters (one per failing candidate), and a candidate's failure is counted
297
- * even when a sibling candidate ultimately proved. Explicit-policy pins bump NOTHING (the router
298
- * never ran). */
299
- export interface RoutingInspect {
300
- /** Successful DERIVED routes, per room key. */
301
- derived: Record<string, number>;
302
- /** Derivation failures, per {@link RoutingFailureReason}. */
303
- reasons: Record<string, number>;
304
- }
305
-
306
- /** Why a §3 derivation (or one candidate's proof) fell to the daemon — the {@link RoutingInspect}
307
- * counter keys. */
308
- export type RoutingFailureReason =
309
- /** No connected room gate with a promoted table — the single-domain fast path. */
310
- | "no-candidates"
311
- /** The invocation ran `tx.query` — a declarative read is not room-provable client-side
312
- * (rindle-cover is native-only BY DESIGN), so the whole derivation fails unconditionally. */
313
- | "tx-query"
314
- /** Two or more candidate rooms both proved. Principled disambiguation is a §9.2 (multi-room
315
- * clients + budgets) NON-goal, deferred past Slice J; routing slow is always sound (the §3.3
316
- * gate is the contract). Reachable essentially only for zero-write mutations — real writes
317
- * can't be writable in two rooms (§2.2 exactly-one-writer per doc). */
318
- | "ambiguous"
319
- /** A write touched a table not promoted for the candidate, or promoted `writable: none`
320
- * (context) — the room may not write it at all. */
321
- | "write-unwritable-table"
322
- /** `writableMatches` refused the write's post-image (add/edit) or pre-image (remove). */
323
- | "write-scope-miss"
324
- /** An edit moved a join-key cell (old vs new differ on a `joinKeyCols` column). */
325
- | "write-join-key-change"
326
- /** The engine's provenance for the write's probe row names a DIFFERENT room. */
327
- | "write-cross-room-provenance"
328
- /** A recorded read was served by a DIFFERENT room. */
329
- | "read-cross-room"
330
- /** A read needed the pk-membership test but the table has no `footprintWhere` (child /
331
- * correlated / inexact extraction / never-promoted table). */
332
- | "read-no-footprint-where"
333
- /** `footprintWhere` references a non-pk column — not decidable from the key alone. */
334
- | "read-not-key-decidable"
335
- /** `footprintWhere` is key-decidable but outside the deliberately-minimal client evaluator
336
- * (an op other than `=`/`!=`, a null/type-mixed comparison, column-vs-column, …). */
337
- | "read-not-evaluable"
338
- /** `footprintWhere` evaluated FALSE on the read's pk — the room provably never holds it. */
339
- | "read-outside-footprint"
340
- /** NOT a derivation failure (H-v): a routed mutation came back `mutationOutcome
341
- * {kind:"deopt"}` — the room GATE refused it at commit and the client re-enqueued it onto the
342
- * daemon. Counted per processed deopt frame so Q6's picture is complete: derived-and-deopted
343
- * routes are visible beside derived successes (a rising `deopt` count against a rising
344
- * `derived` count means the client proof and the gate disagree — a compiler/evaluator skew
345
- * worth investigating; each one costs a room round-trip + a burnt room mid, never
346
- * correctness). */
347
- | "deopt";
348
-
349
243
  interface PendingMutation {
350
244
  /** The wire mutation id. A FOLDED entry carries `null` until its window flushes — the `mid`
351
245
  * is dealt from `nextMid` in SEND order, never reserved at invoke, so the wire sequence stays
@@ -379,21 +273,9 @@ interface PendingMutation {
379
273
  /** The pk-granular write-set captured at this entry's LAST invocation (§3.2 #1). A rebase
380
274
  * re-invocation MERGES its fresh write-set into this one ({@link mergeWriteSet}), mirroring the
381
275
  * `touched` union: the key set only grows across re-invocations (a re-run that no-ops must not
382
- * shrink it, §7.2), each key's value is always the newest. */
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). */
383
278
  writes: WriteSet;
384
- /** The physical SOURCES this mutator's writes staged onto (RINDLE-REALTIME-QUERY-ENABLEMENT-
385
- * DESIGN.md §5.3, from `commitTracked`) — `{"daemon"}` in single-domain. Drives the per-source
386
- * reconcile FILTER: `runReconcileCycle(sourceKey)` re-invokes an entry only if it wrote onto
387
- * `sourceKey` (its writes there were un-applied by that source's rewind; other-source writes are
388
- * intact). Union-merged across re-invocations, never shrunk (like `touched`). DISTINCT from
389
- * {@link domain} (which stream CONFIRMS it): a room-domain mutation whose write routed to the
390
- * daemon slice (a fresh pk) has `domain === "room:doc:X"` but `touchedSources ∋ "daemon"`. */
391
- touchedSources: Set<string>;
392
- /** Per staged (table, pk) → the source it routed onto ({@link writeSourceKey}), aligned from
393
- * `commitTracked`'s staged-order `sources[]` (last write to a pk wins). Read at the confirm-drop
394
- * to fire the §7.3 echo hold-back for a write whose source ≠ its confirming {@link domain}.
395
- * All `"daemon"` in single-domain (inert). Merged never-shrunk across re-invocations. */
396
- writeSources: Map<string, string>;
397
279
  /** The read-log captured at this entry's LAST *recorded* invocation (§3.2 #2). Empty for a
398
280
  * FOLDED entry (the trap, not recording, arms on that path — nothing is ever recorded there)
399
281
  * and left as the ORIGINAL invoke's log across a rebase re-invocation: recording is armed only
@@ -492,14 +374,12 @@ interface SourceGate {
492
374
  /** Arrival counter behind {@link BufferedFrame.seq}, scoped to THIS gate's buffer. */
493
375
  nextSeq: number;
494
376
  appliedCv: number;
495
- /** The channel's latest UPSTREAM-absorption advert301 direction B, a ROOM gate only): "I
496
- * have absorbed upstream daemon commits through cv `upstreamCv` of boot `upstreamBoot`"
497
- * recorded verbatim from each {@link ProgressFrame} ({@link OptimisticBackend.onGateProgress})
498
- * so the drop pass that runs at that release's tail reads exactly this release's advert.
499
- * `undefined` until the shell advertises (an old shell never does — the §2.5 skew fallback);
500
- * cleared on a channel restart (the fresh incarnation re-advertises). */
501
- upstreamBoot?: string;
502
- upstreamCv?: 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>;
503
383
  }
504
384
 
505
385
  /** One I-iv doorbell event ({@link OptimisticBackend.onScopeSessions}, §4.1): a release folded
@@ -513,21 +393,23 @@ export interface ScopeSessionsEvent {
513
393
  others: number;
514
394
  }
515
395
 
516
- /** One demoted room source's §4.2 ghost record (Slice I-v): the room slice stays FROZEN in the
517
- * merge (wins-if-present still supplying the visible rows, accepting nothing) until the
518
- * daemon plane has provably absorbed the room's final flush. The drop condition is evaluated
519
- * after every release ({@link OptimisticBackend.evaluateGhosts}):
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}):
520
402
  *
521
403
  * `roomWatermarks[doc] ≥ finalFlushSeq` (0 ⇒ trivially true — a never-flushed room)
522
404
  * AND no pending mutation with `domain === sourceKey` remains (sent-pins-domain, §7.5 —
523
405
  * room-domain entries retire ONLY through the outcome-resolved daemon-carried folds, I-iii).
524
- */
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. */
525
410
  interface RoomGhost {
526
411
  doc: string;
527
412
  finalFlushSeq: number;
528
- /** Every table promoted for the sourceKey at demote time — the {@link dropGhost} removal set
529
- * (and, while the ghost lives, its hold on the {@link promotedTables} record). */
530
- tables: string[];
531
413
  /** Whether the ONE stuck-downgrade event already fired for this ghost. */
532
414
  stuckReported: boolean;
533
415
  }
@@ -543,41 +425,18 @@ export interface DowngradeStuckEvent {
543
425
  mids: number[];
544
426
  }
545
427
 
546
- /** One §301 echo-fence pin (`301-ECHO-FENCE-DESIGN.md` §2.1): the client-side registry entry for
547
- * a parked engine hold-back, carrying the DELIVERY-fence inputs the engine deliberately does not
548
- * know. Parked in the same breath as `holdBack`/`holdBackAbsent` at the §7.3 confirm-drop; keyed
549
- * `${table}\0${sourceKey}\0${pkKey}` so a re-park of the same slice-pk overwrites (matching the
550
- * engine's `held_back.insert`). The fence per direction (§1):
551
- *
552
- * - **A** (`sourceKey === "daemon"`, a room-domain write staged on the daemon slice): drops
553
- * when the DAEMON-CARRIED room ledger covers `mid` — {@link OptimisticBackend}'s
554
- * `daemonCarriedLmid[domain] ≥ mid` (the I-ii co-commit puts the flush data in the same
555
- * coherent release, §1.1).
556
- * - **B** (a room `sourceKey`, `domain === "daemon"`): drops when the room gate advertises
557
- * absorption at-or-past the parking daemon release — `(upstreamBoot, upstreamCv)` covers
558
- * `(daemonBoot, daemonCv)` under the §2.4 boot rule.
559
- * - a room-staged pin confirmed by ANOTHER room has no fence (out of today's two-tier
560
- * topology); it drops only via the engine's state-match fallback. */
561
- interface EchoFencePin {
562
- table: string;
563
- /** The staged slice holding the engine pin — what to drop ON. */
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 {
564
436
  sourceKey: string;
565
- /** The held row / pre-image (full-width; the pk derives from it) — what to drop AT. */
566
- probeRow: WireValue[];
567
- /** The confirming domain (direction A's ledger to watch). */
568
- domain: string;
569
- /** The confirmed wire mid (direction A's fence input). */
570
- mid: number;
571
- /** Direction B stamps (301 §1.2): the parking daemon release's coherence position + the
572
- * client-observed daemon boot it belongs to. Absent on direction A / the no-fence shape. */
573
- daemonBoot?: string;
574
- daemonCv?: number;
575
- /** The slice's confirmed-baseline row at park — the §2.5 stuck-pin tripwire's "has the
576
- * baseline CHANGED VALUE since park" reference (that change is the suspicious state that
577
- * precedes every forever-pin). */
578
- baselineAtPark?: WireValue[];
579
- /** The tripwire's log-once latch. */
580
- tripwired?: boolean;
437
+ name: string;
438
+ args: unknown;
439
+ tables: string[];
581
440
  }
582
441
 
583
442
  export interface OptimisticBackendOptions {
@@ -593,14 +452,13 @@ export interface OptimisticBackendOptions {
593
452
  /** Virtual-clock seam for the fold debounce timers (FOLDED-MUTATIONS-DESIGN §9). Defaults to
594
453
  * real `setTimeout`/`clearTimeout`/`Date.now`; the fold oracle injects a deterministic clock. */
595
454
  clock?: FoldClock;
596
- /** The explicit confirming-stream OVERRIDE (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1 /
597
- * §3). Since H-iii the live default is the §3 prove-or-slow-path DERIVATION
598
- * ({@link OptimisticBackend.deriveDomain}): a configured policy returning a string pins that
599
- * domain VERBATIM — no proof runs (the test suites and the dual-source e2e pin by name);
600
- * returning `undefined` (or configuring no policy) derives the domain from the prediction run's
601
- * write/read capture against the connected room gates' routing table. With no room gate
602
- * connected the derivation short-circuits to `"daemon"` — every single-domain app is
603
- * byte-for-byte as before. */
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. */
604
462
  domainPolicy?: (name: string, args: unknown) => string | undefined;
605
463
  /** A FINAL (authz/validation) mutation rejection's reason surface — the room plane's twin of the
606
464
  * HTTP mutate route's `onRejected` (H-v; the H-iv-b `mutationOutcome {kind:"rejected"}` frame).
@@ -680,8 +538,8 @@ const REAL_CLOCK: FoldClock = {
680
538
  };
681
539
 
682
540
  /** The (shared, frozen-by-convention) empty map {@link OptimisticBackend.roomTablesFor} answers
683
- * for a room with no promoted tables. */
684
- const EMPTY_ROUTING: ReadonlyMap<string, RoomTableRouting> = new Map();
541
+ * for a room with no registered tables. */
542
+ const EMPTY_ROOM_TABLES: ReadonlyMap<string, string> = new Map();
685
543
 
686
544
  /** Per-domain retention cap for the processed-outcome set (H-v) — mirrors the shell's
687
545
  * `MAX_RECORDED_OUTCOMES_PER_CLIENT`: the sender caps what it can re-answer at 512 per client,
@@ -774,15 +632,23 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
774
632
  * an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
775
633
  * takes 512 interleaving non-applied outcomes on one domain to open it. */
776
634
  private readonly outcomesProcessed = new Map<string, Set<number>>();
777
- /** THE routing table (H-iii §3): per connected room `sourceKey` → per promoted table → its
778
- * client-held {@link RoomTableRouting}. Written ONLY by {@link promoteRoomTable} (same breath
779
- * as the engine attach + {@link promotedTables}); read by {@link deriveDomain} and — via
780
- * {@link roomTablesFor} the client's `__realtimeInspect` bookkeeping. */
781
- private readonly roomRouting = new Map<string, Map<string, RoomTableRouting>>();
782
- /** Q6 counters ({@link RoutingInspect}): successful derivations per room. */
783
- private readonly routingDerived = new Map<string, number>();
784
- /** Q6 counters ({@link RoutingInspect}): derivation failures per {@link RoutingFailureReason}. */
785
- private readonly routingReasons = new Map<string, 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>();
786
652
  /** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
787
653
  * (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
788
654
  private readonly folds = new Map<string, FoldRecord>();
@@ -805,47 +671,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
805
671
  * scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
806
672
  * synthetic tables are daemon-tracked by design). */
807
673
  private readonly daemonGate: SourceGate;
808
- /** Tables promoted to a MERGED multi-source engine (a room source attached). Written by the one
809
- * promotion seam ({@link promoteRoomTable}; the test-named {@link __addRoomSource} and Slice
810
- * G-v's lease-driven promotion from `RoomTableSpec[]` both flow through it). Read by the §7.3
811
- * hold-back trigger in
812
- * {@link applyRelease}: parking on a never-promoted (Collapsed) table is SKIPPED — inert anyway
813
- * today (`rewind_collapsed` never consults `held_back`), and a live hazard if the table later
814
- * promotes (a stale Collapsed-era entry turns overlay-first-visible and pins forever). Slice H's
815
- * prove-or-slow-path routing keeps the real scenario — a room-confirmed mutation writing a
816
- * Collapsed table — impossible; this gate covers the window until then. */
817
- private readonly promotedTables = new Set<string>();
818
- /** The §301 pin registry ({@link EchoFencePin}): every parked §7.3 hold-back's delivery-fence
819
- * inputs, keyed `${table}\0${sourceKey}\0${pkKey}`. Written in the same breath as the
820
- * `holdBack`/`holdBackAbsent` park; read by {@link dropEchoFencePins} at the tail of every
821
- * release. Empty on every single-domain client — the drop pass is then a structural no-op. */
822
- private readonly pins = new Map<string, EchoFencePin>();
823
- /** The §301 direction-A fence input: per room domain, the highest lmid the DAEMON-CARRIED
824
- * ledger rows have folded ({@link foldSystemFrames} step 2 — and ONLY that path: the room
825
- * socket's own lmid stream confirms long before the flush reaches the daemon, so it folds
826
- * into the shared {@link watermark} but never here, 301 §1.1). When this covers a pin's mid,
827
- * the same coherent release (or an earlier one) folded the flush data that carried it into
828
- * the daemon baseline — the fence. */
829
- private readonly daemonCarriedLmid = new Map<string, number>();
830
- /** The §301 §2.4 boot rule's inputs: the daemon boot ids this client has OBSERVED, in order
831
- * (id → ordinal), plus the current one. Boot ids are opaque — ordering is the client's own
832
- * observation ({@link OptimisticSource.onBootId} on the daemon gate). Direction-B pins stamp
833
- * the current boot; a room advertising a LATER-observed boot proves absorption (its
834
- * re-snapshot came from a daemon state that durably includes the confirmed write). */
835
- private readonly daemonBootOrdinals = new Map<string, number>();
836
- private daemonBootId: string | undefined;
837
- /** The per-read provenance probe `invoke` hands `trackingTx` when recording is armed (H-ii,
838
- * §3.2 #3): the engine's `provenanceOf` — the VISIBLE overlay-first winner for the row's pk,
839
- * read from LIVE pre-commit state (an open txn's staged writes are not consulted; H-i). Gated
840
- * on PER-TABLE {@link promotedTables} membership — the cheapest existing signal, and the
841
- * correct one: provenance is an ENGINE-merge question, not a channel question ({@link gates}
842
- * can hold a connected-but-unpromoted room feed, and the oracle harness promotes with no gate),
843
- * and a never-promoted (Collapsed) table is daemon-owned by construction. So a pure
844
- * single-domain app pays ZERO wasm calls per recorded read; the recorded `source` is then
845
- * `undefined`, which is also correct — everything is daemon-owned (see {@link ReadRecord}). */
846
- private readonly readProvenance = (table: string, row: WireValue[]): string | undefined =>
847
- this.promotedTables.has(table) ? this.local.provenanceOf(table, row) : undefined;
848
-
849
674
  // --- the §4 lifecycle SYSTEM-STREAM plane (Slice I-iii) --------------------------------
850
675
  /** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
851
676
  * and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
@@ -887,6 +712,12 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
887
712
  * only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
888
713
  * Default no-op. */
889
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>();
890
721
 
891
722
  private readonly asts = new Map<QueryId, Ast>();
892
723
  /** Per query: the base tables its result can draw from (from the AST tree). */
@@ -968,12 +799,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
968
799
  source.onNormalized((qid, ev) => this.onFrame(gate, qid, ev));
969
800
  source.onProgress((frame) => this.onGateProgress(gate, frame));
970
801
  source.onRestart?.(() => this.resetGate(gate));
971
- // §301 §2.4: the DAEMON channel's observed boot-id stream seeds the client-local boot
972
- // ordinals the direction-B fence's boot rule compares against (boot ids are opaque; order is
973
- // the client's own observation). Only the daemon gate's boots matter — a room's advertised
974
- // `upstreamBoot` names a DAEMON boot, and room-channel incarnations are handled by
975
- // `resetGate`. Optional: an in-process source never reports one.
976
- if (key === "daemon") source.onBootId?.((bootId) => this.observeDaemonBoot(bootId));
977
802
  // The deopt handshake's client half (H-v §3.3): the channel's `mutationOutcome` frames arrive
978
803
  // as `(domain = gate.key, frame)`. OUT-OF-BAND — the source dispatches on arrival and this
979
804
  // handler runs immediately, NEVER behind the gate's cv buffer: a deopt must migrate its entry
@@ -1006,7 +831,49 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1006
831
  if (this.gates.has(sourceKey)) {
1007
832
  throw new Error(`optimistic backend: source ${sourceKey} is already connected`);
1008
833
  }
1009
- this.attachGate(sourceKey, source, new NormalizedSync(this.pkCols, this.colCounts));
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;
1010
877
  }
1011
878
 
1012
879
  // --- the Backend seam ---------------------------------------------------------
@@ -1047,7 +914,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1047
914
  // child is left a native reduce (L1) — `rewriteAggregates`/`ensureSyntheticTables` skip it.
1048
915
  this.ensureSyntheticTables(qid, ast);
1049
916
  // Local first (synchronous empty view), then the server stream hydrates it.
1050
- this.local.registerQuery(qid, rewriteAggregates(ast, (t) => this.localTables.has(t)));
917
+ this.local.registerQuery(qid, this.plainEngineAst(ast));
1051
918
  if (remote) {
1052
919
  // A remote query is `unknown` until its first server snapshot lands (hydration); retainRemote
1053
920
  // attaches it to the sub and sets the lifecycle against the sub's hydration state.
@@ -1117,6 +984,8 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1117
984
  }
1118
985
 
1119
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
1120
989
  const remoteQid = this.releaseRemote(qid);
1121
990
  // GC: rows this remote footprint SOLELY referenced fall to refcount 0 → net removes. A qid
1122
991
  // lives on ONE channel, so at most one gate's dropQuery is non-empty (dropQuery of an
@@ -1126,7 +995,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1126
995
  this.pendingRetargetGc.delete(remoteQid); // the sweep below covers a mid-retarget teardown
1127
996
  for (const gate of this.gates.values()) {
1128
997
  gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
1129
- const gc = gate.sync.dropQuery(remoteQid);
998
+ const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
1130
999
  if (gc.length) gcs.push([gate.key, gc]);
1131
1000
  }
1132
1001
  }
@@ -1176,7 +1045,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1176
1045
  // Per-gate sweep, like `unregisterQuery`: at most one gate owned this qid's frames/rows.
1177
1046
  for (const gate of this.gates.values()) {
1178
1047
  gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
1179
- const gc = gate.sync.dropQuery(remoteQid);
1048
+ const gc = mapGateDeltas(gate, gate.sync.dropQuery(remoteQid));
1180
1049
  if (gc.length) this.runReconcileCycle(gate.key, gc);
1181
1050
  }
1182
1051
  }
@@ -1199,17 +1068,15 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1199
1068
  * precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
1200
1069
  * cutover point), and register on the room source (its resolver presents the handed
1201
1070
  * roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
1202
- * rows visible through the window (merge: the daemon-tier holder). Local views keep their
1203
- * `hydrated` membership the view stays `complete` on its authoritative daemon rows.
1204
- * 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT ({@link flushRetargetGc}): the room source now
1205
- * holds the footprint rows (folded via `serverBatchBegin(sourceKey)` value-equal rows
1206
- * re-hydrate as a net-zero diff, RT §3.4 per channel), so the deferred
1207
- * `dropQuery`+reconcile on the OLD gate flips each pk's winner daemon→room value-equal:
1208
- * net-zero again. No emission carries a disappearance at any point.
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.
1209
1077
  *
1210
1078
  * Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
1211
- * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey), mirroring
1212
- * {@link promoteRoomTable}'s caller-side per-(sourceKey, table) idempotence). Validates before
1079
+ * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey)). Validates before
1213
1080
  * mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
1214
1081
  retargetRemoteQuery(remote: RemoteQuery, sourceKey: string): QueryId {
1215
1082
  const newGate = this.requireGate(sourceKey); // throw loudly BEFORE any sub state moves
@@ -1251,42 +1118,31 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1251
1118
  this.pendingRetargetGc.delete(sourceQid);
1252
1119
  const oldGate = this.gates.get(oldGateKey);
1253
1120
  if (!oldGate) continue;
1254
- const gc = oldGate.sync.dropQuery(sourceQid);
1121
+ const gc = mapGateDeltas(oldGate, oldGate.sync.dropQuery(sourceQid));
1255
1122
  if (gc.length) this.runReconcileCycle(oldGateKey, gc);
1256
1123
  }
1257
1124
  }
1258
1125
 
1259
1126
  // --- the §4.2 downgrade: demote → ghost → fence → drop (Slice I-v) ----------------------
1260
1127
 
1261
- /** The I-v downgrade orchestration primitive (§4.2/§7.4): retire room `sourceKey` behind the
1262
- * watermark fence. The caller has ALREADY retargeted every live sub off the channel
1263
- * ({@link retargetRemoteQuery} room→daemon — validated loudly below) and holds the fence from
1264
- * the api-server's downgrade response (`finalFlushSeq` = the room's last COMMITTED flush seq;
1265
- * `doc` keys the §4.2 watermark fold, {@link roomWatermarks}). Steps, in order:
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:
1266
1134
  *
1267
- * 1. **De-candidacy NOW**: the sourceKey's {@link roomRouting} entries are removed, so
1268
- * {@link deriveDomain} stops proposing the room (zero candidates ⇒ `"daemon"`). Unsent
1269
- * pending (`mid === null`) re-routes for free — a fold's flush re-derives from the live
1270
- * candidate set (§7.5 rule 1). The engine's frozen scope still ANSWERS (`writableMatches`
1271
- * / `provenanceOf` — freezing gates serving, not the scope definition); routing is
1272
- * TS-gated here.
1273
- * 2. **Freeze** the WRITABLE promoted tables (`freezeSource`): the room slice becomes the
1274
- * §4.2 ghost — wins-if-present in the merge (D4), so its rows (at-or-ahead of the daemon
1275
- * until the flush echoes) stay visible while the slice accepts nothing. Context tables
1276
- * (`writable: false`) are deliberately NOT frozen: the daemon is authoritative for them
1277
- * LIVE (§5.2's context tier) and freezing would invert that, pinning a possibly-BEHIND
1278
- * relayed copy over fresher daemon rows; unfrozen they keep exactly the live tiering.
1279
- * 3. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
1135
+ * 1. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
1280
1136
  * dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
1281
1137
  * an assigned mid pins its domain; a later re-upgrade of the same doc continues the
1282
1138
  * sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
1283
1139
  * into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
1284
1140
  * load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
1285
- * gone ⇒ record deleted, nothing dropped) running that GC would rewind the room slice's
1286
- * rows at daemon hydration, i.e. BEFORE the fence, surfacing a lagging follower's stale
1287
- * images (the exact regression §4.2 exists to prevent). The ghost's rows leave only
1288
- * through {@link dropGhost}'s `removeRoomSource`, value-equal under the fence.
1289
- * 4. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
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
1290
1146
  * immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
1291
1147
  * drops on the spot, the single-daemon first-frame case.
1292
1148
  *
@@ -1298,7 +1154,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1298
1154
  if (sourceKey === "daemon") {
1299
1155
  throw new Error("optimistic backend: the daemon source cannot be demoted");
1300
1156
  }
1301
- if (this.ghosts.has(sourceKey)) return; // idempotent — one ghost per source
1302
1157
  // Validate FIRST (nothing mutated yet): a live sub still on the channel would silently
1303
1158
  // starve once the gate detaches — the caller must retarget every sub off the room first.
1304
1159
  for (const sub of this.remoteSubs.values()) {
@@ -1308,13 +1163,22 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1308
1163
  );
1309
1164
  }
1310
1165
  }
1311
- const routing = this.roomRouting.get(sourceKey);
1312
- const tables = routing ? [...routing.keys()] : [];
1313
- const writable = routing ? tables.filter((t) => routing.get(t)!.writable) : [];
1314
- this.roomRouting.delete(sourceKey); // (1) de-candidacy
1315
- for (const table of writable) this.local.freezeSource(table, sourceKey); // (2) the ghost
1316
- this.disconnectSource(sourceKey); // (3) the channel
1317
- this.ghosts.set(sourceKey, { doc, finalFlushSeq, tables, stuckReported: false }); // (4)
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)
1318
1182
  this.evaluateGhosts();
1319
1183
  }
1320
1184
 
@@ -1350,6 +1214,13 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1350
1214
  this.downgradeStuckHandler = handler;
1351
1215
  }
1352
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
+
1353
1224
  /** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
1354
1225
  * the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
1355
1226
  * once at demote time. For each ghost: the fence must be satisfied
@@ -1361,6 +1232,10 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1361
1232
  private evaluateGhosts(): void {
1362
1233
  if (this.ghosts.size === 0) return; // every non-downgrade release: structural no-op
1363
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;
1364
1239
  if ((this.roomWatermarks.get(ghost.doc) ?? 0) < ghost.finalFlushSeq) continue; // fence holds
1365
1240
  const stuck = this.pendingMutations.filter((p) => p.domain === sourceKey && p.mid !== null);
1366
1241
  if (stuck.length > 0) {
@@ -1370,47 +1245,48 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1370
1245
  }
1371
1246
  continue; // hold — never a timeout-retire (§7.5 rule 2)
1372
1247
  }
1373
- this.dropGhost(sourceKey, ghost);
1248
+ this.dropGhost(sourceKey);
1374
1249
  }
1375
1250
  }
1376
1251
 
1377
- /** Drop one cleared ghost (§4.2 end state): detach every promoted table's room source (D2
1378
- * stay-merged `removeRoomSource` reconciles each held pk to daemon-or-vanish, value-equal
1379
- * under the fence, so visually a no-op), release the {@link promotedTables} record for tables
1380
- * no OTHER room/ghost still holds, then run ONE daemon reconcile so entries whose writes had
1381
- * staged onto the ghost slice re-invoke and re-stage by provenance onto the daemon slice (the
1382
- * removal took their staged copies with the tree; provenance now answers daemon). The whole
1383
- * drop runs under one commit boundary so the removal's compensating deltas and the re-staged
1384
- * predictions notify as ONE step. After this, a FUTURE upgrade of the same doc promotes
1385
- * again from scratch the round trip is pinned by the §8.5 lanes. */
1386
- private dropGhost(sourceKey: string, ghost: RoomGhost): void {
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 {
1387
1262
  this.ghosts.delete(sourceKey);
1388
- // Re-stage eligibility FIRST: a pending entry staged (only) on the ghost slice must join the
1389
- // daemon reconcile below (the cycle filter is `touchedSources.has(sourceKey)`) — union, never
1390
- // shrink, the §5.3 rule.
1391
- let restage = false;
1392
- for (const p of this.pendingMutations) {
1393
- if (p.touchedSources.has(sourceKey)) {
1394
- p.touchedSources.add("daemon");
1395
- restage = true;
1396
- }
1397
- }
1398
1263
  this.inOneCommit(() => {
1399
- for (const table of ghost.tables) this.local.removeRoomSource(table, sourceKey);
1400
- for (const table of ghost.tables) {
1401
- let held = false;
1402
- for (const byTable of this.roomRouting.values()) if (byTable.has(table)) held = true;
1403
- for (const g of this.ghosts.values()) if (g.tables.includes(table)) held = true;
1404
- if (!held) this.promotedTables.delete(table);
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));
1405
1271
  }
1406
- // One daemon reconcile replays the re-staged entries onto the daemon slice. Run whenever
1407
- // any pending exists: the removal above may have taken a staged write with the tree even
1408
- // when `touchedSources` never recorded the ghost (defensive; an empty-pending drop skips).
1409
- if (restage || this.pendingMutations.length > 0) this.runReconcileCycle("daemon", []);
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", []);
1410
1276
  });
1411
1277
  this.refreshPending(); // the reconcile may have dropped a throwing re-invocation
1412
1278
  }
1413
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
+
1414
1290
  // --- the §4 lifecycle SYSTEM-STREAM retains (Slice I-iii) ------------------------------
1415
1291
 
1416
1292
  /** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
@@ -1576,192 +1452,63 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1576
1452
  return { mid, seq: ++this.dealSeq };
1577
1453
  }
1578
1454
 
1579
- // --- the §3 prove-or-slow-path ROUTER (H-iii) -----------------------------------
1580
- //
1581
- // RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3: a mutation routes to room R iff every row its
1582
- // prediction run WROTE is provably inside R's writable scope and every read it RECORDED is
1583
- // provably covered by R's footprint — ANY unproven condition routes slow (the §0 asymmetry:
1584
- // mis-guessing "slow" costs one round-trip; mis-guessing "room" would commit divergent data).
1585
- // Failure is therefore SILENT "daemon" — never a throw, never a warning per call — plus the Q6
1586
- // per-reason counters ({@link RoutingInspect}).
1587
- //
1588
- // The proof evaluates against the ENGINE wherever the engine has an answer: `writableMatches`
1589
- // (the registered compiled writable scope — the same predicate the merge's winner tiering uses)
1590
- // and `provenanceOf` (the visible overlay-first winner). It runs AFTER the prediction committed,
1591
- // so the probes see the prediction's own staged effects — which only ever strengthens the
1592
- // conservative direction (a fresh pk reports "daemon"/undefined and neither disqualifies).
1455
+ // --- the DECLARED router (302 §5: declared, not derived) --------------------------------
1593
1456
  //
1594
- // ** THE ONE CLIENT-SIDE NON-ENGINE EVALUATOR deliberately minimal (the TS evaluation
1595
- // caveat). ** Evaluating `footprintWhere` on a read's pk cells ({@link evalFootprintOnPk}) is
1596
- // the single place Slice H allows a non-engine evaluator, and it is restricted to STRICT cell
1597
- // equality only: `simple` `=` / `!=` between a pk column and a same-primitive-type non-null
1598
- // literal, composed under and/or (the empty AND is the vacuous-true exact-unconstrained-root
1599
- // emission and evaluates true). ANY other op or shape LIKE, <, >=, IS, IN, null operands,
1600
- // cross-type comparisons, column-vs-column is NOT EVALUABLE and fails to the daemon.
1601
- // Justification: a mis-evaluation here cannot commit divergence — the room GATE (H-iv) re-proves
1602
- // every write and absent read engine-side at commit and deopts, so a wrong client verdict only
1603
- // costs a wasted hop (route room → gate deopt) or a skipped optimization (route daemon); and
1604
- // restricting to strict equality on non-null, same-typed pk cells removes the entire
1605
- // null/collation/coercion divergence space between this evaluator and the engine's comparators.
1606
- // The gate remains the contract; this router is the optimization.
1607
- //
1608
- // ** H-v (SHIPPED — the router's missing recovery half). ** A derived (or pinned) room route
1609
- // whose gate DEOPTS (the H-iv-b `mutationOutcome {kind:"deopt"}` frame — the mid is burnt in
1610
- // the room ledger, no effects committed) is re-enqueued onto the daemon stream by
1611
- // {@link handleMutationOutcome}: the entry flips in place (fresh daemon mid, ORIGINAL seq,
1612
- // prediction applied throughout), or already retired re-invokes from the frame's echoed
1613
- // name/args pinned to the daemon. A reconnect re-sends unconfirmed mids so a frame lost with
1614
- // its socket is re-answered ({@link resendPending}). A wrong client verdict therefore costs
1615
- // exactly one room round-trip plus one burnt room-mid (§3.3), never a stranded prediction.
1616
-
1617
- /** The route decision for one invocation (invoke, and again at fold flush): the explicit
1618
- * `domainPolicy` is the OVERRIDE a string pins that domain verbatim, no proof runs;
1619
- * `undefined` (or no policy configured) derives per §3. */
1620
- private resolveDomain(name: string, args: unknown, writes: WriteSet, reads: ReadLog): string {
1621
- const pinned = this.domainPolicy(name, args);
1622
- if (pinned !== undefined) return pinned;
1623
- return this.deriveDomain(writes, reads);
1624
- }
1625
-
1626
- /** §9.3: does this fold's write-set route into a room (vs the daemon)? Used ONLY to pick the
1627
- * {@link FoldOptions.roomDebounceMs} cadence at a fold window's start. It probes
1628
- * {@link resolveDomain} but RESTORES the Q6 routing diagnostics afterward — the authoritative
1629
- * route and its single counter bump belong to the flush ({@link flushFold}), not to this
1630
- * interval hint. A fold's reads are provably empty (the read-trap arms on that path), so the
1631
- * write-set alone decides. */
1632
- private routesToRoom(name: string, args: unknown, writes: WriteSet): boolean {
1633
- const reasons = [...this.routingReasons];
1634
- const derived = [...this.routingDerived];
1635
- const domain = this.resolveDomain(name, args, writes, { reads: [], queries: [] });
1636
- this.routingReasons.clear();
1637
- for (const [k, v] of reasons) this.routingReasons.set(k, v);
1638
- this.routingDerived.clear();
1639
- for (const [k, v] of derived) this.routingDerived.set(k, v);
1640
- return domain !== "daemon";
1641
- }
1642
-
1643
- /** Count one derivation failure ({@link RoutingInspect}). Returns `false` so the per-candidate
1644
- * proof's call sites read `return this.failDerivation(...)`. */
1645
- private failDerivation(reason: RoutingFailureReason): false {
1646
- this.routingReasons.set(reason, (this.routingReasons.get(reason) ?? 0) + 1);
1647
- return false;
1648
- }
1649
-
1650
- /** The §3 derivation. Candidates are the connected room gates with at least one promoted table
1651
- * (a promoted table with no gate cannot confirm; a gate with no promoted table holds no data to
1652
- * prove against). Zero candidates ⇒ `"daemon"` (the single-domain fast path). The proof runs
1653
- * per candidate and EXACTLY ONE must survive — two rooms both proving routes slow (principled
1654
- * disambiguation is a §9.2 multi-room NON-goal, deferred past Slice J; slow is always sound). */
1655
- private deriveDomain(writes: WriteSet, reads: ReadLog): string {
1656
- const candidates: string[] = [];
1657
- for (const key of this.gates.keys()) {
1658
- if (key === "daemon") continue;
1659
- if ((this.roomRouting.get(key)?.size ?? 0) > 0) candidates.push(key);
1660
- }
1661
- if (candidates.length === 0) {
1662
- this.failDerivation("no-candidates");
1663
- return "daemon";
1664
- }
1665
- // tx.query is not room-executable client-side: predicate containment (rindle-cover) is
1666
- // native-only BY DESIGN, so a declarative read fails the WHOLE derivation unconditionally.
1667
- // This is required, not conservative.
1668
- if (reads.queries.length > 0) {
1669
- this.failDerivation("tx-query");
1670
- return "daemon";
1671
- }
1672
- const proven = candidates.filter((room) => this.provesRoom(room, writes, reads));
1673
- if (proven.length === 1) {
1674
- const room = proven[0];
1675
- this.routingDerived.set(room, (this.routingDerived.get(room) ?? 0) + 1);
1676
- return room;
1677
- }
1678
- if (proven.length > 1) this.failDerivation("ambiguous");
1679
- return "daemon"; // zero survivors: each candidate already counted its own failure reason
1680
- }
1681
-
1682
- /** One candidate room's §3 proof over the captured write-set + read-log. Every write must pass
1683
- * ALL the write rules; every read must pass ONE of the read rules. First failure wins (and is
1684
- * counted); order is deterministic (write-set map order, then read-log order). */
1685
- private provesRoom(room: string, writes: WriteSet, reads: ReadLog): boolean {
1686
- const specs = this.roomRouting.get(room);
1687
- // --- write rules: every WriteRecord passes ALL of #1–#4 -------------------------------
1688
- for (const [table, byPk] of writes) {
1689
- // #1: the table is promoted for R with a writable (predicate-kind) spec. A context table
1690
- // (`writable: none`), an un-promoted table, and a local table (unreachable — the mutator
1691
- // guard refuses local writes at stage time) all fail here.
1692
- const spec = specs?.get(table);
1693
- if (spec === undefined || !spec.writable) return this.failDerivation("write-unwritable-table");
1694
- const colIx = this.colIndex[table];
1695
- for (const rec of byPk.values()) {
1696
- const removeShape = rec.row === undefined;
1697
- // #2: the registered writable scope, evaluated BY THE ENGINE on the post-image for
1698
- // add/edit shapes and on the pre-image for removes (the row the room would delete). A
1699
- // remove record always carries its full-width pre-image (the H-ii capture contract);
1700
- // a violated contract fails closed rather than probing nothing.
1701
- const scopeRow = removeShape ? rec.oldRow : rec.row;
1702
- if (scopeRow === undefined || !this.local.writableMatches(table, room, scopeRow)) {
1703
- return this.failDerivation("write-scope-miss");
1704
- }
1705
- // #3: join-key no-change. An edit-shape (row + oldRow — the oldRow is the txn-entry base)
1706
- // must keep every joinKeyCols cell STRICTLY identical old-vs-new; an add-shape may SET
1707
- // them (it creates the correlation); removes are exempt. A join-key column missing from
1708
- // the schema is a spec bug — fail closed.
1709
- if (!removeShape && rec.oldRow !== undefined) {
1710
- for (const col of spec.joinKeyCols) {
1711
- const i = colIx?.get(col);
1712
- if (i === undefined || !identicalCell(rec.oldRow[i], rec.row![i])) {
1713
- return this.failDerivation("write-join-key-change");
1714
- }
1715
- }
1716
- }
1717
- // #4: provenance corroboration — the engine routes an Edit by its OLD row (H-i), so probe
1718
- // the pre-image for edit/remove shapes and the post-image for add-shapes. A winner naming
1719
- // a DIFFERENT room disqualifies; "daemon"/undefined does NOT (a thin slice, or a fresh pk
1720
- // — fresh pks stage to the daemon slice by the E2 decision, kept permanently: the
1721
- // room-side authoritative run is the committing execution either way).
1722
- const probeRow = rec.oldRow ?? rec.row;
1723
- if (probeRow !== undefined) {
1724
- const src = this.local.provenanceOf(table, probeRow);
1725
- if (src !== undefined && src !== "daemon" && src !== room) {
1726
- return this.failDerivation("write-cross-room-provenance");
1727
- }
1728
- }
1729
- }
1730
- }
1731
- // --- read rules: every ReadRecord passes ONE ------------------------------------------
1732
- // (tx.query already failed the whole derivation in deriveDomain — never reaches here.)
1733
- for (const r of reads.reads) {
1734
- // Self-read: the pk is in THIS invocation's write-set for that table — the write rules
1735
- // above already judged it (covers the keyed writers' probe-then-write shape). NOTE the
1736
- // read-log reflects the ORIGINAL invoke only (the pre-existing capture caveat).
1737
- if (writes.get(r.table)?.has(stableJson(r.pk))) continue;
1738
- if (r.source !== undefined) {
1739
- if (r.source === room) continue; // the room served the row — in-footprint by construction
1740
- if (r.source !== "daemon") return this.failDerivation("read-cross-room");
1741
- }
1742
- // Present-with-daemon/undefined source, or ABSENT (source is never recorded for those —
1743
- // including the un-promoted-table meaning of an absent key): the footprint-membership test
1744
- // on the PK ALONE. R's footprintWhere must exist, be key-decidable (every column it reads is
1745
- // a pk column), and evaluate TRUE on the read's pk cells:
1746
- // present-daemon + TRUE ⇒ the row is in the room's COMPLETE footprint ⇒ covered;
1747
- // absent + TRUE ⇒ absent-in-room = absent-in-truth ⇒ covered;
1748
- // FALSE (either outcome) ⇒ fail — present-daemon-FALSE means the room lacks the row;
1749
- // absent-FALSE is arguably provable (decidably-outside ⇒ the room never sees the pk)
1750
- // but the room-side run would then read absent for a DIFFERENT reason than truth's —
1751
- // stay CONSERVATIVE and fail;
1752
- // no footprintWhere / not key-decidable / not evaluable ⇒ fail (see the evaluator caveat
1753
- // on the router block comment).
1754
- const fw = specs?.get(r.table)?.footprintWhere;
1755
- if (fw === undefined) return this.failDerivation("read-no-footprint-where");
1756
- const tspec = this.specs[r.table];
1757
- const pkCols = tspec.primaryKey.map((i) => tspec.columns[i]);
1758
- if (!keyDecidable(fw, new Set(pkCols))) return this.failDerivation("read-not-key-decidable");
1759
- const pkCells = new Map<string, WireValue>(pkCols.map((c, j) => [c, r.pk[j]]));
1760
- const verdict = evalFootprintOnPk(fw, pkCells);
1761
- if (verdict === undefined) return this.failDerivation("read-not-evaluable");
1762
- if (!verdict) return this.failDerivation("read-outside-footprint");
1763
- }
1764
- return true;
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
+ );
1765
1512
  }
1766
1513
 
1767
1514
  /** Run the named client mutator optimistically: the prediction applies to the live
@@ -1783,46 +1530,37 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1783
1530
  // One commit boundary spans the prediction AND the `__agg`-head reconcile below, so their views
1784
1531
  // (data + count) flush together rather than tearing across two engine commits.
1785
1532
  return this.inOneCommit(() => {
1786
- // 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),
1787
1539
  // the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
1788
1540
  // propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
1789
1541
  // silently refuses every later mutation from this client (#10).
1790
1542
  const writes: WriteSet = new Map();
1791
1543
  const reads: ReadLog = { reads: [], queries: [] };
1792
1544
  const ops: ChildOp[] = [];
1793
- // Per-staged-write source keys (staged order), zipped with the `commitTracked` `sources[]`
1794
- // below to learn which physical source each pk routed onto (§5.3 filter + §7.3 hold-back).
1795
- const stagedKeys: string[] = [];
1796
- const sources = this.local.writeWith((tx) => {
1545
+ this.local.writeWith((tx) => {
1797
1546
  this.runMutator(
1798
1547
  mutator,
1799
- // `readProvenance` rides only with recording (H-ii §3.2 #3): the folded path (trap, no
1800
- // readLog) and the reconcile replay (no readLog) never probe — see trackingTx.
1801
- trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, stagedKeys, this.readProvenance),
1548
+ trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, this.stagingMap(domain)),
1802
1549
  args,
1803
1550
  );
1804
1551
  });
1805
1552
  // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1806
1553
  const touched = new Set(writes.keys());
1554
+ this.warnCrossAuthorityWrites(name, domain, touched);
1807
1555
  // Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
1808
1556
  // this write does, so wire order == local-apply order for any pair that can observe each other
1809
1557
  // (a read-dependent write reading a folded cell sees the same value optimistically and on the
1810
1558
  // wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
1811
1559
  this.drainOverlapping(touched);
1812
- // The confirming stream (§7.1): its ledger deals the mid and its watermark alone retires the
1813
- // entry. THE §3 ROUTER RUNS HERE (H-iii)after the prediction (write/read capture is
1814
- // complete, and the engine holds the committed prediction the probes read) and BEFORE the
1815
- // mid is dealt: an assigned mid pins its domain forever (§7.1) — a re-invocation never
1816
- // re-routes. An explicit `domainPolicy` string pins verbatim (no proof); an H-v deopt
1817
- // re-invocation pins via `pin` (see {@link invokeWith}) and the router never runs.
1818
- const domain = pin ?? this.resolveDomain(name, args, writes, reads);
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.
1819
1562
  const { mid, seq } = this.dealMid(domain);
1820
- // The write-source axes (§5.3): which physical source each write staged onto. All `"daemon"`
1821
- // in single-domain (Collapsed) — so the filter and hold-back below are inert on the live path.
1822
- const touchedSources = new Set(sources);
1823
- const writeSources = new Map<string, string>();
1824
- mergeWriteSources(writeSources, stagedKeys, sources);
1825
- this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads, touchedSources, writeSources });
1563
+ this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads });
1826
1564
  // The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
1827
1565
  // the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
1828
1566
  // delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
@@ -1845,6 +1583,9 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1845
1583
  const foldKey = `${name}\0${stableJson(opts.key)}`;
1846
1584
  // One commit boundary spans the prediction AND the `__agg`-head reconcile (see {@link inOneCommit}),
1847
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);
1848
1589
  return this.inOneCommit(() => {
1849
1590
  // Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
1850
1591
  // compute its write is non-absorbing and refused. A throw discards the staged write (clean
@@ -1852,11 +1593,9 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1852
1593
  // path stays byte-for-byte as it was; recording (§3.2 #2) never arms alongside the trap.
1853
1594
  const writes: WriteSet = new Map();
1854
1595
  const ops: ChildOp[] = [];
1855
- const stagedKeys: string[] = [];
1856
- let sources: string[] = [];
1857
1596
  try {
1858
- sources = this.local.writeWith((tx) => {
1859
- this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined, stagedKeys), args);
1597
+ this.local.writeWith((tx) => {
1598
+ this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined, this.stagingMap(domain)), args);
1860
1599
  });
1861
1600
  } catch (e) {
1862
1601
  if (e instanceof FoldReadError) {
@@ -1871,37 +1610,30 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1871
1610
 
1872
1611
  // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1873
1612
  const touched = new Set(writes.keys());
1874
- // The write-source axes (§5.3), like `touched`/`writes` — REPLACED wholesale on an in-place
1875
- // fold overwrite (the latest invocation supersedes, absorbing), initialized on a new entry.
1876
- const touchedSources = new Set(sources);
1877
- const writeSources = new Map<string, string>();
1878
- mergeWriteSources(writeSources, stagedKeys, sources);
1613
+ this.warnCrossAuthorityWrites(name, domain, touched);
1879
1614
  const now = this.clock.now();
1880
1615
  let f = this.folds.get(foldKey);
1881
1616
  if (f) {
1882
1617
  // Overwrite the single entry in place — the pending stack does NOT grow (§1 #2). The head
1883
1618
  // already carries this new prediction (absorbing, last-wins on the cell); the entry holds
1884
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).
1885
1623
  f.entry.args = args;
1886
1624
  f.entry.touched = touched;
1887
1625
  f.entry.writes = writes;
1888
- f.entry.touchedSources = touchedSources;
1889
- f.entry.writeSources = writeSources;
1626
+ f.entry.domain = domain;
1890
1627
  f.args = args;
1891
1628
  this.clock.clearTimeout(f.timer);
1892
1629
  } else {
1893
- // Provisional domain (§7.1): re-resolved from the final args/write-set at flush, when the
1894
- // mid is dealt. Never read before then — an un-flushed fold (`mid == null`) is
1895
- // unconditionally retained — so the full (counting) derivation deliberately does NOT run
1896
- // here (it would double-bump the Q6 counters for one logical route); the flush derives.
1897
- const domain = this.domainPolicy(name, args) ?? "daemon";
1898
1630
  // §9.3: pick the window's cadence. Routing into a room ⇒ flush at roomDebounceMs so
1899
1631
  // intermediates stream to the shared head; off the room, the caller's collapse debounce
1900
- // governs. The probe restores the Q6 diagnostics (the flush owns the authoritative bump).
1901
- const inRoom = opts.roomDebounceMs !== undefined && this.routesToRoom(name, args, writes);
1632
+ // governs.
1633
+ const inRoom = opts.roomDebounceMs !== undefined && domain !== "daemon";
1902
1634
  const debounceMs = inRoom ? opts.roomDebounceMs! : opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS;
1903
1635
  const maxWaitMs = inRoom ? opts.roomDebounceMs! : opts.maxWaitMs;
1904
- const entry: PendingMutation = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] }, touchedSources, writeSources };
1636
+ const entry: PendingMutation = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] } };
1905
1637
  this.pendingMutations.push(entry);
1906
1638
  let resolveMid!: (mid: number) => void;
1907
1639
  const midPromise = new Promise<number>((res) => (resolveMid = res));
@@ -1951,13 +1683,11 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
1951
1683
  if (!f) return;
1952
1684
  this.clock.clearTimeout(f.timer);
1953
1685
  this.folds.delete(foldKey);
1954
- // Resolve the confirming stream from the FINAL args (§7.1) and deal the mid from that domain's
1955
- // ledger — SEND order, never reserved, so gapless within the domain. Under H-iii derivation
1956
- // this RE-DERIVES from the entry's LATEST write-set (each in-place fold overwrite replaced it,
1957
- // so it reflects the final absorbed value): same §3 rules minus reads — a fold's read-set is
1958
- // provably empty by construction (the FoldReadError trap, not recording, arms on that path),
1959
- // which is the STRONGEST read proof there is. The mid dealt below then pins this domain.
1960
- const domain = this.resolveDomain(f.entry.name, f.args, f.entry.writes, f.entry.reads);
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);
1961
1691
  f.entry.domain = domain;
1962
1692
  const { mid, seq } = this.dealMid(domain);
1963
1693
  f.entry.mid = mid;
@@ -2076,8 +1806,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2076
1806
  );
2077
1807
  return;
2078
1808
  }
2079
- // kind === "deopt": Q6's completion how often a routed mutation came back refused.
2080
- this.routingReasons.set("deopt", (this.routingReasons.get("deopt") ?? 0) + 1);
1809
+ // kind === "deopt": the room gate refused a declared-room mutation re-enqueue onto the daemon.
2081
1810
  const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
2082
1811
  if (entry) {
2083
1812
  entry.domain = "daemon";
@@ -2261,85 +1990,42 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2261
1990
  nextMid: Record<string, number>;
2262
1991
  watermark: Record<string, number>;
2263
1992
  /** Per connected CHANNEL (§5.1): its release watermark + buffered-frame depth — the axis the
2264
- * gate-isolation assertions read (one source's laggy cvMin must never move the other's)
2265
- * plus the §301 upstream-absorption advert last recorded from its progress frames (a ROOM
2266
- * gate with a new shell; absent otherwise). */
2267
- gates: Record<string, { appliedCv: number; bufferedFrames: number; upstreamCv?: number; upstreamBoot?: string }>;
2268
- /** The §3 router's counters (H-iii) — Q6's measurement hook: how often derivation succeeded
2269
- * (per derived room) and why it fell to the daemon (per reason). See {@link RoutingInspect}
2270
- * for the counting discipline (per-candidate failures; pins bump nothing). */
2271
- routing: RoutingInspect;
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>;
2272
1999
  /** The §4 lifecycle plane's folded state (Slice I-iii introspection): the per-doc §4.2 fence
2273
2000
  * value (`roomWatermarks`, I-v's ghost-drop input), the per-scope §4.1 occupancy map
2274
2001
  * (`scopeSessions`: scope → client_id → expires_at, I-iv's doorbell input), and the live
2275
- * I-v ghosts (demoted room sources still awaiting their fence). */
2002
+ * I-v ghosts (demoted room sources still awaiting their swap-back fence). */
2276
2003
  lifecycle: {
2277
2004
  roomWatermarks: Record<string, number>;
2278
2005
  scopeSessions: Record<string, Record<string, number>>;
2279
- ghosts: Record<string, { doc: string; finalFlushSeq: number; tables: string[] }>;
2006
+ ghosts: Record<string, { doc: string; finalFlushSeq: number }>;
2280
2007
  };
2281
- /** The live §301 pin registry (`301-ECHO-FENCE-DESIGN.md` §2.5 the devtools/Q6 surface):
2282
- * every parked hold-back's fence inputs plus its tripwire state, keyed
2283
- * `${table}\0${sourceKey}\0${pkKey}`. Pruned lazily, so an entry here may briefly outlive
2284
- * its engine pin (never the reverse). */
2285
- pins: Record<
2286
- string,
2287
- {
2288
- table: string;
2289
- sourceKey: string;
2290
- domain: string;
2291
- mid: number;
2292
- daemonBoot?: string;
2293
- daemonCv?: number;
2294
- tripwired: boolean;
2295
- }
2296
- >;
2297
- /** The §301 direction-A fence map: per room domain, the highest DAEMON-CARRIED ledger lmid. */
2298
- daemonCarriedLmid: Record<string, number>;
2299
- pending: { mid: number | null; seq: number | null; name: string; domain: string; touchedSources: string[]; writeSources: Record<string, string> }[];
2008
+ pending: { mid: number | null; seq: number | null; name: string; domain: string }[];
2300
2009
  } {
2301
2010
  return {
2302
2011
  nextMid: Object.fromEntries(this.nextMid),
2303
2012
  watermark: Object.fromEntries(this.watermark),
2304
2013
  gates: Object.fromEntries(
2305
- [...this.gates].map(([k, g]) => [
2306
- k,
2307
- {
2308
- appliedCv: g.appliedCv,
2309
- bufferedFrames: g.buffer.length,
2310
- ...(g.upstreamCv !== undefined ? { upstreamCv: g.upstreamCv } : {}),
2311
- ...(g.upstreamBoot !== undefined ? { upstreamBoot: g.upstreamBoot } : {}),
2312
- },
2313
- ]),
2014
+ [...this.gates].map(([k, g]) => [k, { appliedCv: g.appliedCv, bufferedFrames: g.buffer.length }]),
2314
2015
  ),
2315
- routing: {
2316
- derived: Object.fromEntries(this.routingDerived),
2317
- reasons: Object.fromEntries(this.routingReasons),
2318
- },
2016
+ roomTables: Object.fromEntries(
2017
+ [...this.roomTables].map(([k, m]) => [k, Object.fromEntries(m)]),
2018
+ ),
2019
+ swappedViews: Object.fromEntries(this.roomSwappedViews),
2319
2020
  lifecycle: {
2320
2021
  roomWatermarks: Object.fromEntries(this.roomWatermarks),
2321
2022
  scopeSessions: Object.fromEntries(
2322
2023
  [...this.scopeSessions].map(([scope, sessions]) => [scope, Object.fromEntries(sessions)]),
2323
2024
  ),
2324
2025
  ghosts: Object.fromEntries(
2325
- [...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq, tables: [...g.tables] }]),
2026
+ [...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq }]),
2326
2027
  ),
2327
2028
  },
2328
- pins: Object.fromEntries(
2329
- [...this.pins].map(([k, p]) => [
2330
- k,
2331
- {
2332
- table: p.table,
2333
- sourceKey: p.sourceKey,
2334
- domain: p.domain,
2335
- mid: p.mid,
2336
- ...(p.daemonBoot !== undefined ? { daemonBoot: p.daemonBoot } : {}),
2337
- ...(p.daemonCv !== undefined ? { daemonCv: p.daemonCv } : {}),
2338
- tripwired: p.tripwired === true,
2339
- },
2340
- ]),
2341
- ),
2342
- daemonCarriedLmid: Object.fromEntries(this.daemonCarriedLmid),
2343
2029
  pending: this.pendingMutations.map((p) => ({
2344
2030
  mid: p.mid,
2345
2031
  // The client-global deal sequence — the REPLAY order (mids are per-domain, incomparable
@@ -2347,11 +2033,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2347
2033
  seq: p.seq,
2348
2034
  name: p.name,
2349
2035
  domain: p.domain,
2350
- // The write-source axes (§5.3): which physical source(s) this entry's writes routed onto —
2351
- // the filter basis, and (per pk) the hold-back's cross-slice test. The harness asserts the
2352
- // filter/routing against its independent model with these.
2353
- touchedSources: [...p.touchedSources],
2354
- writeSources: Object.fromEntries(p.writeSources),
2355
2036
  })),
2356
2037
  };
2357
2038
  }
@@ -2449,16 +2130,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2449
2130
  * delta, lmid → watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
2450
2131
  * N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
2451
2132
  private onGateProgress(gate: SourceGate, frame: ProgressFrame): void {
2452
- // §301 direction B: record the frame's upstream-absorption advert BEFORE the release applies,
2453
- // so the drop pass at applyRelease's tail evaluates exactly this release's advert — the room
2454
- // emits data-then-progress on one socket, so by the time the advert says "absorbed through
2455
- // cv U" the re-published echo data is in this release's fold (never ahead of it). Verbatim,
2456
- // not max-folded: on a daemon restart the advertised cv space legitimately resets and the
2457
- // §2.4 boot rule (not monotonicity) carries the ordering.
2458
- if (frame.upstreamCv !== undefined) {
2459
- gate.upstreamCv = frame.upstreamCv;
2460
- gate.upstreamBoot = frame.upstreamBoot;
2461
- }
2462
2133
  const { deltas, newlyHydrated, touchedScopes } = this.computeRelease(gate, frame);
2463
2134
  this.applyRelease(gate.key, deltas, undefined, newlyHydrated);
2464
2135
  // I-iv phase 2: a retargeted sub whose first ROOM snapshot released just now gets its old
@@ -2516,8 +2187,11 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2516
2187
  this.foldLmidOps(f.ops, gate.key);
2517
2188
  continue;
2518
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).
2519
2193
  muts.push(
2520
- ...(f.kind === "snapshot" ? gate.sync.rehydrate(f.qid, f.ops) : gate.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)),
2521
2195
  );
2522
2196
  // A query's first released snapshot is its hydration point — even an empty one (0 rows is an
2523
2197
  // authoritative answer): lift every local view this sub feeds out of `unknown` (loading).
@@ -2547,38 +2221,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2547
2221
  if (watermarkUpdate !== undefined) {
2548
2222
  this.watermark.set(sourceKey, Math.max(this.watermark.get(sourceKey) ?? 0, watermarkUpdate));
2549
2223
  }
2550
- // Echo hold-back (§7.3), BEFORE the confirm-drop: an entry `p` this release CONFIRMS
2551
- // (`p.mid <= watermark[p.domain]`) but which staged a write onto a DIFFERENT source `S` (a
2552
- // cross-slice write — e.g. a room-domain mutation whose write routed to the daemon slice) is
2553
- // about to leave `pendingMutations`, so it will NOT be re-invoked on `S`'s next rewind. Park each
2554
- // such write inertly on `S` so that rewind (which un-applies the not-re-invoked write) does not
2555
- // flash-revert it. Two variants, one per write shape (G-i unified them engine-side):
2556
- // - ADD/EDIT (`rec.row` present): pinned PRESENCE — drops when `S`'s confirmed baseline
2557
- // value-matches (its echo lands — the E-i-tested drop).
2558
- // - REMOVE (`rec.row === undefined`, G-iii): pinned ABSENCE — the §7.3 tombstone, parked with
2559
- // the captured pre-image (`rec.oldRow`) so `S`'s rewind cannot resurrect the deleted row;
2560
- // drops when `S`'s baseline lacks the pk (the delete echoed).
2561
- // Inert in single-domain: every write's source equals its domain.
2562
- for (const p of this.pendingMutations) {
2563
- if (p.mid === null || p.mid > (this.watermark.get(p.domain) ?? 0)) continue; // not confirmed now
2564
- for (const [table, byPk] of p.writes) {
2565
- // The Collapsed-park gate (G-iii): NEVER park on an un-promoted table. Inert-anyway today
2566
- // (`rewind_collapsed` never consults `held_back`), so skipping is behaviorally identical —
2567
- // but a stale Collapsed-era entry would become overlay-first-visible (a permanent pin) if
2568
- // the table later promotes. Slice H's prove-or-slow-path routing makes the real scenario
2569
- // (a room-confirmed mutation writing a Collapsed table) impossible; this covers until then.
2570
- if (!this.promotedTables.has(table)) continue;
2571
- for (const [pkKey, rec] of byPk) {
2572
- const s = p.writeSources.get(writeSourceKey(table, pkKey));
2573
- if (s === undefined || s === p.domain) continue; // same-slice: the rewind replays it
2574
- if (rec.row !== undefined) this.local.holdBack(table, s, rec.row);
2575
- else if (rec.oldRow !== undefined) this.local.holdBackAbsent(table, s, rec.oldRow);
2576
- else continue;
2577
- // §301 §2.1: register the pin's delivery-fence inputs in the same breath as the park.
2578
- this.registerPin(table, s, pkKey, rec.row ?? (rec.oldRow as WireValue[]), p);
2579
- }
2580
- }
2581
- }
2582
2224
  // Drop confirmed pending (§1.3 step 5's bookkeeping half), PER DOMAIN: an entry is retired only
2583
2225
  // when ITS domain's watermark reaches its mid — so two concurrent streams never alias one counter
2584
2226
  // (§7.1). A failed mutation drops the same way (the release carries no effects, so the rewind snaps
@@ -2627,11 +2269,11 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2627
2269
  for (const qid of newlyHydrated) this.handler(qid, { type: "batch", events: [], catchUp: true });
2628
2270
  }
2629
2271
  this.refreshPending();
2630
- // The §301 echo-fence drop pass — strictly AFTER the reconcile above folded this release's
2631
- // data, so a fence that cleared in this release drops its pin only once the co-committed
2632
- // echo data is actually in the baseline (dropping earlier would itself reopen the flash
2633
- // window §7.3 closes). Structural no-op with no pins (every single-domain client).
2634
- this.dropEchoFencePins();
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();
2635
2277
  // The I-v ghost-drop watcher (§4.2), LAST: this release's watermark rows have folded
2636
2278
  // (computeRelease) and its confirm-drop has retired what it covers — exactly the two inputs
2637
2279
  // the drop condition reads. Structural no-op with no ghost.
@@ -2647,164 +2289,58 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2647
2289
  this.applyRelease(sourceKey, deltas, watermarkUpdate);
2648
2290
  }
2649
2291
 
2650
- // --- the §301 echo fences (301-ECHO-FENCE-DESIGN.md) -----------------------------------
2292
+ // --- the 302 §4 swap-in ------------------------------------------------------------------
2651
2293
 
2652
- /** Register (or overwrite a later cross-slice confirm re-pinning the same slice-pk matches
2653
- * the engine's `held_back.insert`) one {@link EchoFencePin}, in the same breath as the
2654
- * engine park (§2.1). `p` is the confirmed entry whose write staged onto `sourceKey`; its
2655
- * `mid` is non-null by the caller's confirm filter. */
2656
- private registerPin(
2657
- table: string,
2658
- sourceKey: string,
2659
- pkKey: string,
2660
- probeRow: WireValue[],
2661
- p: PendingMutation,
2662
- ): void {
2663
- const pin: EchoFencePin = {
2664
- table,
2665
- sourceKey,
2666
- probeRow,
2667
- domain: p.domain,
2668
- mid: p.mid as number,
2669
- };
2670
- if (sourceKey !== "daemon" && p.domain === "daemon") {
2671
- // Direction B stamps (§1.2): the parking daemon release's coherence position — the confirm
2672
- // folded at-or-before it, so a room advertising absorption ≥ this cv has absorbed the
2673
- // commit that carried the write — plus the client-observed boot it belongs to (§2.4).
2674
- pin.daemonCv = this.daemonGate.appliedCv;
2675
- if (this.daemonBootId !== undefined) pin.daemonBoot = this.daemonBootId;
2676
- }
2677
- // The tripwire reference (§2.5): the slice's confirmed-baseline row at park.
2678
- pin.baselineAtPark = this.local.heldBackState(table, sourceKey, probeRow)?.baseline as
2679
- | WireValue[]
2680
- | undefined;
2681
- this.pins.set(`${table}\0${sourceKey}\0${pkKey}`, pin);
2682
- }
2683
-
2684
- /** The §2.3 drop pass, run at the tail of every applied release: prune registry entries whose
2685
- * engine pin is already gone (the rewind's state-match fallback or a whole-source removal beat
2686
- * the fence — benign), drop every pin whose fence cleared (one engine drop each, delivered on
2687
- * the ordinary event stream, bracketed as ONE notification commit), and tripwire the rest. */
2688
- private dropEchoFencePins(): void {
2689
- if (this.pins.size === 0) return; // every single-domain client: structural no-op
2690
- let cleared: [string, EchoFencePin][] | null = null;
2691
- for (const [key, pin] of this.pins) {
2692
- const state = this.local.heldBackState(pin.table, pin.sourceKey, pin.probeRow);
2693
- if (state === undefined) {
2694
- this.pins.delete(key); // engine pin gone (state-match / source removal): lazy prune
2695
- continue;
2696
- }
2697
- if (this.pinFenceCleared(pin)) (cleared ??= []).push([key, pin]);
2698
- else this.maybeTripwirePin(key, pin, state.baseline as WireValue[] | undefined);
2699
- }
2700
- if (!cleared) return;
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();
2701
2309
  this.inOneCommit(() => {
2702
- for (const [key, pin] of cleared) {
2703
- this.pins.delete(key);
2704
- this.local.dropHeldBack(pin.table, pin.sourceKey, pin.probeRow);
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
+ }
2705
2340
  }
2706
2341
  });
2707
2342
  }
2708
2343
 
2709
- /** Has `pin`'s delivery fence provably closed its confirm→echo window? (§2.3/§2.4.) */
2710
- private pinFenceCleared(pin: EchoFencePin): boolean {
2711
- if (pin.sourceKey === "daemon") {
2712
- // Direction A: the daemon-carried ledger for the confirming room domain covers the mid —
2713
- // the I-ii co-commit means this release (or an earlier one) folded the flush data that
2714
- // carried it into the daemon baseline (§1.1).
2715
- return (this.daemonCarriedLmid.get(pin.domain) ?? 0) >= pin.mid;
2716
- }
2717
- // Direction B: only a daemon-confirmed write has the daemon-cv stamp; a room-staged pin
2718
- // confirmed by ANOTHER room (outside today's two-tier topology) has no fence.
2719
- if (pin.daemonCv === undefined) return false;
2720
- const gate = this.gates.get(pin.sourceKey);
2721
- if (!gate || gate.upstreamCv === undefined) return false; // no advert (old shell): §2.5 fallback
2722
- if (gate.upstreamBoot === pin.daemonBoot) return gate.upstreamCv >= pin.daemonCv;
2723
- // Boot mismatch (§2.4): a boot the client has OBSERVED as later proves absorption (the
2724
- // room's post-restart re-snapshot came from daemon state that durably includes the
2725
- // confirmed write); an unknown/older/unstamped boot holds — conservative, its next
2726
- // re-snapshot advances it.
2727
- if (pin.daemonBoot === undefined || gate.upstreamBoot === undefined) return false;
2728
- const pinOrd = this.daemonBootOrdinals.get(pin.daemonBoot);
2729
- const advOrd = this.daemonBootOrdinals.get(gate.upstreamBoot);
2730
- return pinOrd !== undefined && advOrd !== undefined && advOrd > pinOrd;
2731
- }
2732
-
2733
- /** The §2.5 stuck-pin tripwire, in the scopesHash spirit: log ONCE per pin when its slice's
2734
- * baseline row has CHANGED VALUE since park while the pin still holds — the suspicious state
2735
- * that precedes every forever-pin (a fence-less pairing, a fence bug). Never drops anything. */
2736
- private maybeTripwirePin(key: string, pin: EchoFencePin, baseline: WireValue[] | undefined): void {
2737
- if (pin.tripwired) return;
2738
- const same =
2739
- pin.baselineAtPark === undefined || baseline === undefined
2740
- ? pin.baselineAtPark === baseline
2741
- : pin.baselineAtPark.length === baseline.length &&
2742
- pin.baselineAtPark.every((c, i) => identicalCell(c, baseline[i]));
2743
- if (same) return;
2744
- pin.tripwired = true;
2745
- const [table, sourceKey, pkKey] = key.split("\0");
2746
- console.warn(
2747
- `rindle: a §7.3 hold-back pin on table "${table}" slice "${sourceKey}" pk ${pkKey} (confirming domain ${pin.domain}, mid ${pin.mid}) is parked while its slice's baseline moved past it — if this client never converges on that row, this pin is why (301-ECHO-FENCE-DESIGN.md §2.5).`,
2748
- );
2749
- }
2750
-
2751
- /** Record one observed daemon boot id (§2.4): first observation of an id assigns the next
2752
- * ordinal (the client's own total order over opaque boot ids); every call refreshes the
2753
- * current-boot stamp for direction-B parks. */
2754
- private observeDaemonBoot(bootId: string): void {
2755
- if (!this.daemonBootOrdinals.has(bootId)) {
2756
- this.daemonBootOrdinals.set(bootId, this.daemonBootOrdinals.size);
2757
- }
2758
- this.daemonBootId = bootId;
2759
- }
2760
-
2761
- /** Promote `table` to a MERGED multi-source engine with room `sourceKey`'s per-row writable
2762
- * scope (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §5.2) — THE one promotion seam. The engine
2763
- * attach and the {@link promotedTables} record move in the same breath: the §7.3 hold-back
2764
- * trigger in {@link applyRelease} parks ONLY on promoted tables, so a promotion that bypassed
2765
- * the record would silently disable the echo hold-back for that table (and a record without the
2766
- * engine attach would park onto a slice that doesn't exist). Recorded AFTER the engine accepts —
2767
- * a rejected descriptor/table must not leave a phantom promotion. Slice G-v's client drives this
2768
- * from the lease's `realtime.tables` (`RoomTableSpec` → {@link WritableDescriptor}); idempotence
2769
- * per `(sourceKey, table)` is the CALLER's job (the engine refuses a duplicate room).
2770
- *
2771
- * `spec` (H-iii) is the per-table ROUTING spec riding the same lease table block — recorded
2772
- * into {@link roomRouting} (THE routing table the §3 router reads) in the same breath, with
2773
- * `writable` derived from the descriptor's kind (`none` = a context table the room may not
2774
- * write). Omitted (the E-iii-b harness alias below) ⇒ an empty spec: no join keys to guard, no
2775
- * `footprintWhere` (that room's reads then fail closed to the daemon unless self/room-served). */
2776
- promoteRoomTable(
2777
- table: string,
2778
- sourceKey: string,
2779
- writable: WritableDescriptor,
2780
- spec?: RoomTableRoutingSpec,
2781
- ): void {
2782
- this.local.addRoomSource(table, sourceKey, writable);
2783
- this.promotedTables.add(table);
2784
- let byTable = this.roomRouting.get(sourceKey);
2785
- if (!byTable) this.roomRouting.set(sourceKey, (byTable = new Map()));
2786
- byTable.set(table, {
2787
- writable: writable.kind !== "none",
2788
- joinKeyCols: [...(spec?.joinKeyCols ?? [])],
2789
- ...(spec?.where !== undefined ? { where: spec.where } : {}),
2790
- ...(spec?.footprintWhere !== undefined ? { footprintWhere: spec.footprintWhere } : {}),
2791
- });
2792
- }
2793
-
2794
- /** The recorded routing specs for `sourceKey`'s promoted tables (table → its
2795
- * {@link RoomTableRouting}) — read-only: the client's `__realtimeInspect`/idempotence
2796
- * bookkeeping reads THIS record instead of keeping its own shadow copy (one source of truth).
2797
- * Empty map when the room has promoted nothing. */
2798
- roomTablesFor(sourceKey: string): ReadonlyMap<string, RoomTableRouting> {
2799
- return this.roomRouting.get(sourceKey) ?? EMPTY_ROUTING;
2800
- }
2801
-
2802
- /** Test-named alias of {@link promoteRoomTable} (E-iii-b scaffolding — the multi-domain oracle
2803
- * and per-source-gate suites drive it). Pure delegation: ONE body, one `promotedTables` record. */
2804
- __addRoomSource(table: string, roomKey: string, writable: WritableDescriptor, spec?: RoomTableRoutingSpec): void {
2805
- this.promoteRoomTable(table, roomKey, writable, spec);
2806
- }
2807
-
2808
2344
  /** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
2809
2345
  * `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
2810
2346
  * `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
@@ -2935,16 +2471,7 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
2935
2471
  const [doc, clientId, lmid] = cells;
2936
2472
  if (typeof doc !== "string" || clientId !== this.clientID) continue;
2937
2473
  if (spec.doc !== undefined && doc !== spec.doc) continue;
2938
- const covered = Number(lmid);
2939
- this.foldConfirm(roomDomainKey(doc), covered);
2940
- // The §301 direction-A fence input: the DAEMON-CARRIED ledger fold ALONE (the room
2941
- // socket's own lmid stream confirms long before the flush reaches the daemon, so it
2942
- // folds the shared watermark above but never this map — 301 §1.1). Max-fold: ledger
2943
- // rows re-deliver across re-hydrates.
2944
- if (Number.isFinite(covered)) {
2945
- const dom = roomDomainKey(doc);
2946
- this.daemonCarriedLmid.set(dom, Math.max(this.daemonCarriedLmid.get(dom) ?? 0, covered));
2947
- }
2474
+ this.foldConfirm(roomDomainKey(doc), Number(lmid));
2948
2475
  }
2949
2476
  }
2950
2477
  // (3) watermark rows → the monotone §4.2 fence value per doc.
@@ -3023,58 +2550,53 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
3023
2550
  * deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
3024
2551
  * boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
3025
2552
  * and sync never diverge (the §1.2 invariant; CRIT#2). */
3026
- private runReconcileCycle(sourceKey: string, serverDeltas: Mutation[]): void {
3027
- // `sourceKey` names the authority these `deltas` confirm — `"daemon"` on the live daemon path (and
3028
- // the GC path), a `room:doc:X` string on a per-source release (E-iii). It selects which physical
3029
- // source the wasm engine's rewind folds the delta into (`Db::serverBatchBegin`). The E-iii-b/c
3030
- // per-source rewind + intersecting-pending re-invoke filter ride the same seam later.
3031
- this.local.serverBatchBegin(sourceKey, serverDeltas.map(toServerOp));
3032
- // The aggregate overlay (§4) is DAEMON-tracked: its `__agg_*` tables are server-authoritative
3033
- // synthetic tables that only the daemon feed drives. A room cycle must not wipe or rebuild it
3034
- // (that would double-count / lose daemon agg state), so gate every overlay interaction reset,
3035
- // observe, and reconcileAggHead on the daemon cycle. Single-domain: always daemon unchanged.
3036
- const daemonCycle = sourceKey === "daemon";
3037
- if (daemonCycle) {
3038
- // The rewind cleared every optimistic write incl. prior `__agg` edits so head is now the
3039
- // server baseline. Rebuild the optimistic agg delta from scratch off the re-invoked
3040
- // (confirm-filtered) pending set, so a just-confirmed mutation's delta vanishes exactly as its
3041
- // server count is absorbed (§5 watermark no double count).
3042
- this.overlay.reset();
3043
- }
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.
2560
+ this.local.serverBatchBegin(serverDeltas.map(toServerOp));
2561
+ // The rewind covers EVERY tracked table (302: the engine is source-agnostic there 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.
2570
+ this.overlay.reset();
3044
2571
  // Sort ALL pending into SEND order (the client-global `seq` ascending, then unflushed folds
3045
2572
  // last by creation order — the deterministic §4.1 slot; the comparator is explicit, NOT
3046
- // `(seq ?? ∞) - (seq ?? ∞)` which is `∞ - ∞ = NaN` and corrupts V8's sort), THEN FILTER to the
3047
- // writers of THIS source5.3): a per-source rewind un-applied ONLY that source's staged
3048
- // writes, so ONLY those must re-invoke the others' predictions are intact on their un-rewound
3049
- // trees. The key MUST be `seq`, never `mid`: mids are per-domain (§7.1) so mids from different
3050
- // domains are incomparable — a mid-sort would replay a room mid 1 before a daemon mid 5 that
3051
- // was sent FIRST, letting a read-dependent mutator re-predict from a base it never saw
3052
- // (confirmation order is per-domain; replay order is client-global). FILTERING the
3053
- // globally-ordered array preserves the relative send order among the subset (the 200 §4.1
3054
- // invariant); NEVER re-sort the subset. Single-domain: every entry has
3055
- // `touchedSources = {"daemon"}` and seq order == mid order (except H-v deopt re-enqueues,
3056
- // which keep their ORIGINAL seq under a later daemon mid deliberately, so this very sort
3057
- // replays them at their original overlay position) byte-identical to before.
3058
- const order = [...this.pendingMutations]
3059
- .sort((a, b) => {
3060
- if (a.seq === null && b.seq === null) return 0; // both unflushed → stable creation order
3061
- if (a.seq === null) return 1; // an unflushed fold sorts after every dealt seq
3062
- if (b.seq === null) return -1;
3063
- return a.seq - b.seq;
3064
- })
3065
- .filter((p) => p.touchedSources.has(sourceKey));
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).
2582
+ const order = [...this.pendingMutations].sort((a, b) => {
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;
2587
+ });
3066
2588
  const dropped = new Set<PendingMutation>();
3067
2589
  try {
3068
2590
  for (const p of order) {
3069
2591
  // NO `readLog` here — recording is armed only on the initial `invoke` (§3.2 #2 note on
3070
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.
3071
2595
  const writes: WriteSet = new Map();
3072
2596
  const ops: ChildOp[] = [];
3073
- const stagedKeys: string[] = [];
3074
- let sources: string[] = [];
3075
2597
  try {
3076
- sources = this.local.writeWith((tx) => {
3077
- this.runMutator(this.registry[p.name], trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, undefined, stagedKeys), p.args);
2598
+ this.local.writeWith((tx) => {
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);
3078
2600
  });
3079
2601
  } catch {
3080
2602
  // A re-invocation threw — e.g. a read-dependent mutator whose base row the server
@@ -3086,25 +2608,20 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
3086
2608
  dropped.add(p);
3087
2609
  continue;
3088
2610
  }
3089
- // The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta (daemon
3090
- // cycle only a room cycle leaves the daemon-tracked overlay untouched).
3091
- if (daemonCycle) for (const op of ops) this.overlay.observe(op);
2611
+ // The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta.
2612
+ for (const op of ops) this.overlay.observe(op);
3092
2613
  // The pending footprint is the UNION across invocations: a re-run that no-ops (touched =
3093
2614
  // {}) must NOT shrink it, else a still-pending mutation reports not-pending and its
3094
- // pending-axis clear fires early (§7.2). `writes`/the source axes mirror this: merge, never
3095
- // replace. (A re-invocation may route differently, e.g. onto a source the room feed now
3096
- // holds — union so the filter still catches it on either source's next cycle.)
2615
+ // pending-axis clear fires early (§7.2). `writes` mirrors this: merge, never replace.
3097
2616
  for (const t of writes.keys()) p.touched.add(t);
3098
2617
  mergeWriteSet(p.writes, writes);
3099
- for (const s of sources) p.touchedSources.add(s);
3100
- mergeWriteSources(p.writeSources, stagedKeys, sources);
3101
2618
  }
3102
2619
  // Preserve creation order in the live array (the unflushed-fold sort tiebreak depends on it).
3103
2620
  if (dropped.size) this.pendingMutations = this.pendingMutations.filter((p) => !dropped.has(p));
3104
2621
  // Re-apply the optimistic agg delta onto the (rewound) `__agg` head rows — INSIDE the open
3105
2622
  // cycle, so the writes buffer and coalesce into the one per-query delivery `serverBatchEnd`
3106
- // makes (and never escape as a separate batch). Daemon cycle only (see above).
3107
- if (daemonCycle) this.reconcileAggHead();
2623
+ // makes (and never escape as a separate batch). Every cycle (see the reset above).
2624
+ this.reconcileAggHead();
3108
2625
  } finally {
3109
2626
  this.local.serverBatchEnd(); // ALWAYS close the cycle — ONE delivery per affected query.
3110
2627
  }
@@ -3121,10 +2638,6 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
3121
2638
  private resetGate(gate: SourceGate): void {
3122
2639
  gate.buffer = [];
3123
2640
  gate.appliedCv = 0;
3124
- // The §301 upstream-absorption advert dies with the incarnation that made it — the fresh
3125
- // one re-advertises (direction-B pins conservatively hold until it does).
3126
- delete gate.upstreamBoot;
3127
- delete gate.upstreamCv;
3128
2641
  }
3129
2642
 
3130
2643
  /** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
@@ -3160,14 +2673,17 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
3160
2673
  }
3161
2674
 
3162
2675
  /** A remote sub's first snapshot landed: mark it (and every local view it feeds) hydrated, then
3163
- * lift those views out of `unknown` (loading). Idempotent a re-hydrate snapshot re-marks
3164
- * 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. */
3165
2680
  private markSubHydrated(sourceQid: QueryId): void {
3166
2681
  const key = this.sourceToRemote.get(sourceQid);
3167
2682
  if (!key) return;
3168
2683
  const sub = this.remoteSubs.get(key);
3169
2684
  if (!sub || sub.hydrated) return;
3170
2685
  sub.hydrated = true;
2686
+ if (sub.channel !== "daemon" && !this.systemQids.has(sub.sourceQid)) this.pendingSwapIns.add(sub);
3171
2687
  for (const localQid of sub.localQids.keys()) {
3172
2688
  this.hydrated.add(localQid);
3173
2689
  this.recomputeResultType(localQid);
@@ -3205,9 +2721,31 @@ export class OptimisticBackend<S extends ColsMap> implements Backend {
3205
2721
  // A late-joiner to an already-hydrated sub is immediately hydrated; otherwise this view now
3206
2722
  // awaits the sub's first snapshot (so a split-path local view registered `complete` flips to
3207
2723
  // `unknown` here). Then recompute its lifecycle.
3208
- if (sub.hydrated) this.hydrated.add(localQueryId);
3209
- else this.hydrated.delete(localQueryId);
3210
- 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
+ }
3211
2749
  }
3212
2750
  this.localToRemote.set(retainQid, key);
3213
2751
  this.remoteRetainToLocal.set(retainQid, localQueryId);
@@ -3346,103 +2884,53 @@ function mergeWriteSet(dest: WriteSet, src: WriteSet): void {
3346
2884
  }
3347
2885
  }
3348
2886
 
3349
- /** The key a per-pk write source is stored under in {@link PendingMutation.writeSources}: `table`
3350
- * + NUL + the pk-key (`stableJson(pk)`). NUL appears in neither a table name nor `stableJson`
3351
- * output, so it is an unambiguous separator — the same key the confirm-drop hold-back reconstructs
3352
- * from `(table, pkKey)` while iterating `writes`. */
3353
- function writeSourceKey(table: string, pkKey: string): string {
3354
- return `${table}\u0000${pkKey}`;
3355
- }
3356
-
3357
- /** Zip the staged-order per-write source keys (`stagedKeys`, from `trackingTx`) with the staged-order
3358
- * `sources[]` (`commitTracked`) into `dest`, last-write-per-key winning (§5.3). Never shrinks `dest`
3359
- * — a rebase re-invocation MERGES its fresh routing in, mirroring {@link mergeWriteSet}. The two
3360
- * arrays align 1:1: `trackingTx` pushes exactly one key per staged change, and `commitTracked`
3361
- * returns one source per staged change, both in staged order. */
3362
- function mergeWriteSources(dest: Map<string, string>, stagedKeys: string[], sources: string[]): void {
3363
- const n = Math.min(stagedKeys.length, sources.length);
3364
- for (let i = 0; i < n; i++) dest.set(stagedKeys[i], sources[i]);
3365
- }
3366
-
3367
- // --- the §3 router's pure helpers (H-iii) --------------------------------------------
2887
+ // --- the 302 room-table helpers -------------------------------------------------------
3368
2888
 
3369
- /** STRICT cell identity for the join-key no-change rule (§3 write rule #3): `===`, nothing more —
3370
- * no coercion, no deep equality (a non-primitive join-key cell compares by reference and thus
3371
- * conservatively fails), `NaN !== NaN` conservatively fails. Failing closed here only costs a
3372
- * daemon route. */
3373
- function identicalCell(a: WireValue, b: WireValue): boolean {
3374
- return a === b;
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}`;
3375
2895
  }
3376
2896
 
3377
- /** Whether `cond` is decidable from the KEY ALONE: every column it references collected from
3378
- * BOTH operand positions (under-counting could over-claim decidability; over-counting only fails
3379
- * closed the H-iv-a discipline)is a pk column. A `correlatedSubquery` is never key-local. */
3380
- // Exported for test/predicate_agreement.test.ts the TS corner of the cross-evaluator
3381
- // agreement corpus (rust/rindle-room-core/tests/fixtures/predicate-agreement.json). NOT part of
3382
- // the package surface (index.ts curates exports); the corpus is the drift tripwire that keeps
3383
- // this evaluator honest against the engine + room-gate pair it shares no code with.
3384
- export function keyDecidable(cond: Condition, pkCols: ReadonlySet<string>): boolean {
3385
- switch (cond.type) {
3386
- case "simple": {
3387
- if (cond.left.type === "column" && !pkCols.has(cond.left.name)) return false;
3388
- if (cond.right.type === "column" && !pkCols.has(cond.right.name)) return false;
3389
- return true;
3390
- }
3391
- case "and":
3392
- case "or":
3393
- return cond.conditions.every((c) => keyDecidable(c, pkCols));
3394
- case "correlatedSubquery":
3395
- return false;
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 });
3396
2908
  }
2909
+ return out;
3397
2910
  }
3398
2911
 
3399
- /** Evaluate a key-decidable `footprintWhere` on a read's pk cells H-iii's ONE client-side
3400
- * non-engine evaluator, DELIBERATELY MINIMAL (the TS evaluation caveat on the router block
3401
- * comment): `simple` `=`/`!=` between a pk COLUMN and a same-primitive-type NON-NULL literal,
3402
- * composed under and/or. The empty AND the compiler's vacuous-true emission for an exact
3403
- * unconstrained footprint root evaluates `true` (load-bearing: whole-table footprints keep
3404
- * provable reads); the empty OR is vacuous-false. Returns `undefined` = NOT EVALUABLE for
3405
- * anything else (other ops, null on either side, cross-type comparisons, column-vs-column,
3406
- * literal-vs-literal, non-primitive cells, correlated subqueries) the caller fails to the
3407
- * daemon. A non-evaluable node anywhere poisons the whole tree (no short-circuit past it):
3408
- * partial evaluation could otherwise claim a verdict the engine's semantics might contradict. */
3409
- // Exported for test/predicate_agreement.test.ts (see keyDecidable's note above).
3410
- export function evalFootprintOnPk(cond: Condition, pkCells: ReadonlyMap<string, WireValue>): boolean | undefined {
3411
- switch (cond.type) {
3412
- case "simple": {
3413
- if (cond.op !== "=" && cond.op !== "!=") return undefined;
3414
- const col = cond.left.type === "column" ? cond.left : cond.right.type === "column" ? cond.right : undefined;
3415
- const lit = cond.left.type === "literal" ? cond.left : cond.right.type === "literal" ? cond.right : undefined;
3416
- if (col === undefined || lit === undefined) return undefined; // column-vs-column / literal-vs-literal
3417
- if (!pkCells.has(col.name)) return undefined; // not a pk column (keyDecidable pre-screens)
3418
- const cell = pkCells.get(col.name);
3419
- const value = lit.value;
3420
- if (cell === null || value === null) return undefined; // no null semantics client-side
3421
- if (typeof cell !== typeof value) return undefined; // no cross-type comparison semantics
3422
- if (typeof cell !== "string" && typeof cell !== "number" && typeof cell !== "boolean") return undefined;
3423
- return cond.op === "=" ? cell === value : cell !== value;
3424
- }
3425
- case "and": {
3426
- let out = true;
3427
- for (const c of cond.conditions) {
3428
- const v = evalFootprintOnPk(c, pkCells);
3429
- if (v === undefined) return undefined;
3430
- out = out && v;
3431
- }
3432
- return out;
3433
- }
3434
- case "or": {
3435
- let out = false;
3436
- for (const c of cond.conditions) {
3437
- const v = evalFootprintOnPk(c, pkCells);
3438
- if (v === undefined) return undefined;
3439
- out = out || v;
3440
- }
3441
- return out;
3442
- }
3443
- case "correlatedSubquery":
3444
- return undefined;
3445
- }
2912
+ /** Rename every TABLE reference in a query AST through `map` (302 §2 point 3the 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);
3446
2934
  }
3447
2935
 
3448
2936
  /** Wrap the raw wasm txn as the client `MutationTx`, capturing a pk-granular write-set as it
@@ -3490,17 +2978,21 @@ function trackingTx(
3490
2978
  onOp?: (op: ChildOp) => void,
3491
2979
  trapReads = false,
3492
2980
  readLog?: ReadLog,
3493
- stagedKeys?: string[],
3494
- /** Per-read provenance probe (H-ii §3.2 #3): called once per RECORDED present read with the
3495
- * full-width row the read observed; meaningful only alongside `readLog` (never consulted when
3496
- * recording is off the fold and replay paths pass neither). */
3497
- provenance?: (table: string, row: WireValue[]) => string | undefined,
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>,
3498
2987
  ): MutationTx {
3499
2988
  const spec = (table: string) => {
3500
2989
  const s = specs[table];
3501
2990
  if (!s) throw new Error(`unknown table ${JSON.stringify(table)} — tables: ${Object.keys(specs).join(", ")}`);
3502
2991
  return s;
3503
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;
3504
2996
 
3505
2997
  // M1 (`201-LOCAL-ONLY-TABLES-DESIGN.md` §6): a replayable mutator is a pure function of
3506
2998
  // (synced base + args) — it neither READS nor WRITES a local-only table. The server runs the
@@ -3546,17 +3038,15 @@ function trackingTx(
3546
3038
  // this invocation — an add-then-edit (or edit-then-edit) of the same pk collapses to its final
3547
3039
  // image, matching the engine head's own semantics for that pk. The record is replaced with
3548
3040
  // exactly the arguments given: the CALLERS (`edit`/`remove` below, consulting `prior`) decide
3549
- // the pre-image per the H-ii coalescing matrix on {@link WriteRecord}.
3550
- const recordWrite = (table: string, pk: WireValue[], row: WireValue[] | undefined, oldRow?: WireValue[]): void => {
3551
- let byPk = writes.get(table);
3552
- if (!byPk) writes.set(table, (byPk = new Map()));
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()));
3553
3046
  const pkKey = stableJson(pk);
3554
3047
  // Defensive copies: the wasm binding's returned arrays are not contractually immutable/unique,
3555
3048
  // so a captured record must not alias a cell array the engine could later reuse or mutate.
3556
- byPk.set(pkKey, { table, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
3557
- // Staged-order key trace (§5.3): pushed once per staged change, so it zips 1:1 with the staged
3558
- // order of `commitTracked`'s `sources[]` — letting the caller learn each write's routed source.
3559
- stagedKeys?.push(writeSourceKey(table, pkKey));
3049
+ byPk.set(pkKey, { table: engineTable, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
3560
3050
  };
3561
3051
 
3562
3052
  // A full insert row: each cell is `obj[c]`, or `null` for an omitted nullable column (design 206
@@ -3587,22 +3077,17 @@ function trackingTx(
3587
3077
  // trapped path (where `readLog` is never armed) records nothing, exactly as before.
3588
3078
  const rawGet = (table: string, pk: WireValue[]) => {
3589
3079
  assertNotLocal(table, "read");
3590
- return tx.get(table, pk) as WireValue[] | undefined;
3080
+ return tx.get(staged(table), pk) as WireValue[] | undefined;
3591
3081
  };
3592
3082
 
3593
3083
  // Push one {@link ReadRecord} when recording is armed (§3.2 #2/#3): outcome from `row`'s
3594
- // presence, per-read provenance probed with the observed FULL-WIDTH row — present reads only
3595
- // (an absent read has no row to probe with, and records no source; see {@link ReadRecord}).
3596
- // The `source` key is OMITTED (not set to undefined) when there is no answer, so a
3597
- // single-domain record is byte-identical to Slice B's.
3084
+ // presence. Pure capture for inspection.
3598
3085
  const recordRead = (table: string, pk: WireValue[], row: WireValue[] | undefined): void => {
3599
3086
  if (!readLog) return;
3600
- const source = row === undefined ? undefined : provenance?.(table, row);
3601
3087
  readLog.reads.push({
3602
3088
  table,
3603
3089
  pk: [...pk],
3604
3090
  outcome: row === undefined ? "absent" : "present",
3605
- ...(source !== undefined ? { source } : {}),
3606
3091
  });
3607
3092
  };
3608
3093
 
@@ -3625,38 +3110,43 @@ function trackingTx(
3625
3110
  };
3626
3111
 
3627
3112
  // The pk's existing record from THIS invocation, if any — the coalescing-matrix input for
3628
- // `edit`/`remove` below (see {@link WriteRecord}).
3113
+ // `edit`/`remove` below (see {@link WriteRecord}). Keyed by the STAGED name like the records.
3629
3114
  const prior = (table: string, pk: WireValue[]): WriteRecord | undefined =>
3630
- writes.get(table)?.get(stableJson(pk));
3115
+ writes.get(staged(table))?.get(stableJson(pk));
3631
3116
  const add = (table: string, row: WireValue[]) => {
3632
3117
  assertNotLocal(table, "write");
3633
- recordWrite(table, pkFromCells(table, row), row);
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.
3634
3124
  onOp?.({ table, kind: "add", row });
3635
- tx.add(table, row);
3125
+ tx.add(t, row);
3636
3126
  };
3637
3127
  const remove = (table: string, row: WireValue[]) => {
3638
3128
  assertNotLocal(table, "write");
3129
+ const t = staged(table);
3639
3130
  const pk = pkFromCells(table, row);
3640
- // The remove PRE-IMAGE (G-iii, §7.3 tombstone; the H-ii matrix on {@link WriteRecord}):
3641
- // remove-after-edit/-remove keeps the ORIGINAL captured pre-image (the txn-entry base — the
3642
- // net effect is a remove of the row the external world last knew, never the edited transient).
3643
- // Otherwise (first touch, or remove-after-add) the truthful full-width row is the txn-visible
3644
- // one — `tx.get` read BEFORE the remove stages (read-your-writes: an add of this pk earlier in
3645
- // the SAME invocation shows through). Captured NOW so Slice H's writable-predicate evaluation
3646
- // over it needs no migration. Falls back to the caller's asserted `row` when the pk is not
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
3647
3137
  // resident (a raw remove of an absent row) — a captured remove thus always carries a
3648
- // full-width pre-image (the engine width-checks `holdBackAbsent`).
3649
- const oldRow = prior(table, pk)?.oldRow ?? (tx.get(table, pk) as WireValue[] | undefined) ?? row;
3650
- recordWrite(table, pk, undefined, oldRow);
3651
- onOp?.({ table, kind: "remove", row });
3652
- tx.remove(table, row);
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);
3653
3143
  };
3654
3144
  const edit = (table: string, oldRow: WireValue[], newRow: WireValue[]) => {
3655
3145
  assertNotLocal(table, "write");
3146
+ const t = staged(table);
3656
3147
  const pk = pkFromCells(table, newRow);
3657
- // The edit PRE-IMAGE (H-ii; the matrix on {@link WriteRecord}) H-iii's join-key no-change
3658
- // input, and the row the engine routes the Edit by (H-i). First touch: the txn-visible row
3659
- // read BEFORE staging, falling back to the caller's asserted `oldRow` when the pk is not
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
3660
3150
  // resident (covers the pk-MOVING raw edit — the record is keyed by the NEW pk; the pre-image
3661
3151
  // carries the OLD row). Edit-after-edit: keep the FIRST pre-image (the txn-entry base).
3662
3152
  // Edit-after-add / edit-after-remove: the record collapses to a (re-)insert — NO pre-image
@@ -3664,13 +3154,13 @@ function trackingTx(
3664
3154
  const p = prior(table, pk);
3665
3155
  const pre =
3666
3156
  p === undefined
3667
- ? ((tx.get(table, pk) as WireValue[] | undefined) ?? oldRow)
3157
+ ? ((tx.get(t, pk) as WireValue[] | undefined) ?? oldRow)
3668
3158
  : p.row !== undefined && p.oldRow !== undefined
3669
3159
  ? p.oldRow
3670
3160
  : undefined;
3671
- recordWrite(table, pk, newRow, pre);
3672
- onOp?.({ table, kind: "edit", row: newRow, old: oldRow });
3673
- tx.edit(table, oldRow, newRow);
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);
3674
3164
  };
3675
3165
 
3676
3166
  // The folded read trap (§5): a mutator that reads to compute its write is refused. `() => never`
@@ -3688,7 +3178,9 @@ function trackingTx(
3688
3178
  const ast = q.ast();
3689
3179
  for (const t of collectTables(ast)) assertNotLocal(t, "read");
3690
3180
  readLog?.queries.push(ast);
3691
- return tx.query(ast) as QueryResultRow[];
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[];
3692
3184
  };
3693
3185
 
3694
3186
  return {
@@ -3762,4 +3254,3 @@ function intersects(a: Set<string>, b: Set<string>): boolean {
3762
3254
  for (const x of b) if (a.has(x)) return true;
3763
3255
  return false;
3764
3256
  }
3765
-