@rindle/optimistic 0.4.3 → 0.5.0

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/dist/backend.js CHANGED
@@ -37,10 +37,11 @@
37
37
  //
38
38
  // `Store`/`ArrayView` are untouched: this implements `Backend`, so
39
39
  // `new Store(schema, new OptimisticBackend(...))` reuses the whole machinery.
40
- import { CLIENT_MUTATIONS_SCHEMA, driveMutationSync, insertCell, insertPlan, isGeneratorMutator, isoTx, LMID_QUERY_NAME, localTableNames, normalizedTableSchemas, tableSpec, toCell, } from "@rindle/client";
40
+ import { CLIENT_MUTATIONS_SCHEMA, driveMutationSync, insertCell, insertPlan, isGeneratorMutator, isoTx, LMID_QUERY_NAME, localTableNames, normalizedTableSchemas, rowsEqual, tableSpec, toCell, } from "@rindle/client";
41
41
  import { aggTableSchemas, NormalizedSync, rewriteAggregates } from "@rindle/normalized";
42
42
  import { WasmBackend } from "@rindle/wasm";
43
43
  import { AggOverlay, collectAggDefs } from "./agg-overlay.js";
44
+ import { decodeOutcomeRow, LIFECYCLE_TABLE_SCHEMAS, ROOM_CLIENT_MUTATIONS_TABLE, ROOM_MUTATION_OUTCOMES_TABLE, ROOM_WATERMARK_TABLE, roomDomainKey, SCOPE_SESSIONS_TABLE, } from "./system-streams.js";
44
45
  /** The reserved source-qid of the backend's own lmid system query. User/local query ids
45
46
  * are assigned by the `Store` starting at 1, so 0 never collides. */
46
47
  const LMID_QID = 0;
@@ -56,6 +57,13 @@ const REAL_CLOCK = {
56
57
  clearTimeout: (h) => clearTimeout(h),
57
58
  now: () => Date.now(),
58
59
  };
60
+ /** The (shared, frozen-by-convention) empty map {@link OptimisticBackend.roomTablesFor} answers
61
+ * for a room with no promoted tables. */
62
+ const EMPTY_ROUTING = new Map();
63
+ /** Per-domain retention cap for the processed-outcome set (H-v) — mirrors the shell's
64
+ * `MAX_RECORDED_OUTCOMES_PER_CLIENT`: the sender caps what it can re-answer at 512 per client,
65
+ * so remembering more than 512 processed mids per domain buys nothing. */
66
+ const MAX_PROCESSED_OUTCOMES_PER_DOMAIN = 512;
59
67
  export class OptimisticBackend {
60
68
  local;
61
69
  sync;
@@ -78,6 +86,9 @@ export class OptimisticBackend {
78
86
  * its width check. */
79
87
  colCounts;
80
88
  colIndex;
89
+ /** Per-table pk column indices — held so `connectSource` can build a fresh per-source
90
+ * `NormalizedSync` with the same layout the daemon's uses. */
91
+ pkCols;
81
92
  /** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
82
93
  * of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
83
94
  * queries arrive (`ensureSyntheticTables`). */
@@ -96,6 +107,18 @@ export class OptimisticBackend {
96
107
  * pending delta `displayed = server_base ⊕ local_pending_delta` is applied from. */
97
108
  overlay = new AggOverlay();
98
109
  handler = () => { };
110
+ // The local qids whose reconcile-cycle batch delivery this release is their FIRST hydration (empty
111
+ // → full): their batch is the initial result set arriving as a delta, so it is stamped `catchUp`
112
+ // and the Store maps it to the `snapshot` change-phase (a narrator ignores it by default). Non-null
113
+ // only for the duration of the reconcile cycle in `onProgress`; a re-hydrate after a drop is a real
114
+ // footprint diff (genuine change) and is NOT remapped. See {@link ChangeEvent} `catchUp`.
115
+ catchUpQids = null;
116
+ /** Newly-hydrated qids whose reconcile ACTUALLY emitted a (catch-up-stamped) batch — recorded by the
117
+ * local-event forwarder alongside {@link catchUpQids}. After the reconcile, any newly-hydrated qid
118
+ * NOT in here folded nothing (0 rows, or its result already present via a sibling → 0 net muts, or
119
+ * the reconcile was skipped), so `onProgress` sends it an explicit empty catch-up — else its SSR
120
+ * seed would never retire (the view freezes). Non-null only for the reconcile's duration. */
121
+ catchUpEmitted = null;
99
122
  /** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the
100
123
  * local engine's `dispatch` brackets so the Store folds every affected view before notifying any
101
124
  * subscriber (cross-view-atomic notification). All this backend's data deltas originate from the
@@ -103,18 +126,140 @@ export class OptimisticBackend {
103
126
  boundaryHandler = () => { };
104
127
  devObservers = new Set();
105
128
  pendingMutations = [];
106
- nextMid = 1;
129
+ /** The next mid to deal, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1): a client
130
+ * writing through room + daemon concurrently must not alias one lmid counter. Seeded with the
131
+ * `"daemon"` stream at 1; a domain absent from the map starts at 1. In the single-domain
132
+ * configuration only `"daemon"` is ever touched, so the sequence is byte-for-byte as before. */
133
+ nextMid = new Map([["daemon", 1]]);
134
+ /** The client-global deal counter behind {@link PendingMutation.seq}: one sequence across ALL
135
+ * domains, bumped whenever any domain's mid is dealt. The replay order (mids are per-domain and
136
+ * incomparable across domains — see the `seq` field doc). */
137
+ dealSeq = 0;
138
+ /** The explicit confirming-stream override (§7.1/§3) — see
139
+ * {@link OptimisticBackendOptions.domainPolicy}. `undefined` from it ⇒ H-iii derivation. */
140
+ domainPolicy;
141
+ /** The final-rejection reason surface ({@link OptimisticBackendOptions.onRejected}). */
142
+ rejectedHandler;
143
+ /** Processed `(domain, mid)` outcome frames (H-v) — the deopt handshake's idempotence guard: a
144
+ * duplicate frame (the original plus a reconnect re-send's re-answer, or two re-answers across
145
+ * two reconnects) must not double-invoke. Needed precisely because a deopt frame can arrive for
146
+ * an ALREADY-RETIRED mid (the replay gotcha) — "no matching entry" alone cannot distinguish
147
+ * "handle it fresh" from "already handled". Per-domain FIFO, capped like the shell's
148
+ * recorded-outcome map ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}); past the cap a duplicate of
149
+ * an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
150
+ * takes 512 interleaving non-applied outcomes on one domain to open it. */
151
+ outcomesProcessed = new Map();
152
+ /** THE routing table (H-iii §3): per connected room `sourceKey` → per promoted table → its
153
+ * client-held {@link RoomTableRouting}. Written ONLY by {@link promoteRoomTable} (same breath
154
+ * as the engine attach + {@link promotedTables}); read by {@link deriveDomain} and — via
155
+ * {@link roomTablesFor} — the client's `__realtimeInspect` bookkeeping. */
156
+ roomRouting = new Map();
157
+ /** Q6 counters ({@link RoutingInspect}): successful derivations per room. */
158
+ routingDerived = new Map();
159
+ /** Q6 counters ({@link RoutingInspect}): derivation failures per {@link RoutingFailureReason}. */
160
+ routingReasons = new Map();
107
161
  /** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
108
162
  * (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
109
163
  folds = new Map();
110
164
  /** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
111
165
  clock;
112
- /** The high-water confirmed mutation id, folded from the lmid system query's
113
- * RELEASED ops (lmid-as-data) never from a frame. */
114
- confirmedLmid = 0;
115
- buffer = [];
116
- nextSeq = 0;
117
- appliedCv = 0;
166
+ /** The high-water confirmed mutation id, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
167
+ * §7.2 per-domain confirm-drop): an entry with `mid <= watermark[entry.domain]` has been
168
+ * confirmed. The `"daemon"` domain is folded from the lmid system query's RELEASED ops
169
+ * (lmid-as-data) never from a frame; a room domain will fold from its own lmid stream (later
170
+ * slice). Seeded with `"daemon"` at 0; the daemon scalar `confirmedLmid` (devtools) is
171
+ * `watermark.get("daemon")`. */
172
+ watermark = new Map([["daemon", 0]]);
173
+ /** The per-source coherence gates (§5.1), by source key. Seeded with the daemon gate at
174
+ * construction; a room channel attaches later (`connectSource`). Single-domain: one entry,
175
+ * and every gate-generalized path degenerates to the old single-buffer code. NOT the same
176
+ * space as {@link watermark}/{@link nextMid}: a DOMAIN can confirm with no gate connected
177
+ * (the `__testRelease` seam); a gate's `key` names the domain its lmid stream folds into. */
178
+ gates = new Map();
179
+ /** The daemon's gate — the always-present channel (constructor-attached). The devtools
180
+ * scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
181
+ * synthetic tables are daemon-tracked by design). */
182
+ daemonGate;
183
+ /** Tables promoted to a MERGED multi-source engine (a room source attached). Written by the one
184
+ * promotion seam ({@link promoteRoomTable}; the test-named {@link __addRoomSource} and Slice
185
+ * G-v's lease-driven promotion from `RoomTableSpec[]` both flow through it). Read by the §7.3
186
+ * hold-back trigger in
187
+ * {@link applyRelease}: parking on a never-promoted (Collapsed) table is SKIPPED — inert anyway
188
+ * today (`rewind_collapsed` never consults `held_back`), and a live hazard if the table later
189
+ * promotes (a stale Collapsed-era entry turns overlay-first-visible and pins forever). Slice H's
190
+ * prove-or-slow-path routing keeps the real scenario — a room-confirmed mutation writing a
191
+ * Collapsed table — impossible; this gate covers the window until then. */
192
+ promotedTables = new Set();
193
+ /** The §301 pin registry ({@link EchoFencePin}): every parked §7.3 hold-back's delivery-fence
194
+ * inputs, keyed `${table}\0${sourceKey}\0${pkKey}`. Written in the same breath as the
195
+ * `holdBack`/`holdBackAbsent` park; read by {@link dropEchoFencePins} at the tail of every
196
+ * release. Empty on every single-domain client — the drop pass is then a structural no-op. */
197
+ pins = new Map();
198
+ /** The §301 direction-A fence input: per room domain, the highest lmid the DAEMON-CARRIED
199
+ * ledger rows have folded ({@link foldSystemFrames} step 2 — and ONLY that path: the room
200
+ * socket's own lmid stream confirms long before the flush reaches the daemon, so it folds
201
+ * into the shared {@link watermark} but never here, 301 §1.1). When this covers a pin's mid,
202
+ * the same coherent release (or an earlier one) folded the flush data that carried it into
203
+ * the daemon baseline — the fence. */
204
+ daemonCarriedLmid = new Map();
205
+ /** The §301 §2.4 boot rule's inputs: the daemon boot ids this client has OBSERVED, in order
206
+ * (id → ordinal), plus the current one. Boot ids are opaque — ordering is the client's own
207
+ * observation ({@link OptimisticSource.onBootId} on the daemon gate). Direction-B pins stamp
208
+ * the current boot; a room advertising a LATER-observed boot proves absorption (its
209
+ * re-snapshot came from a daemon state that durably includes the confirmed write). */
210
+ daemonBootOrdinals = new Map();
211
+ daemonBootId;
212
+ /** The per-read provenance probe `invoke` hands `trackingTx` when recording is armed (H-ii,
213
+ * §3.2 #3): the engine's `provenanceOf` — the VISIBLE overlay-first winner for the row's pk,
214
+ * read from LIVE pre-commit state (an open txn's staged writes are not consulted; H-i). Gated
215
+ * on PER-TABLE {@link promotedTables} membership — the cheapest existing signal, and the
216
+ * correct one: provenance is an ENGINE-merge question, not a channel question ({@link gates}
217
+ * can hold a connected-but-unpromoted room feed, and the oracle harness promotes with no gate),
218
+ * and a never-promoted (Collapsed) table is daemon-owned by construction. So a pure
219
+ * single-domain app pays ZERO wasm calls per recorded read; the recorded `source` is then
220
+ * `undefined`, which is also correct — everything is daemon-owned (see {@link ReadRecord}). */
221
+ readProvenance = (table, row) => this.promotedTables.has(table) ? this.local.provenanceOf(table, row) : undefined;
222
+ // --- the §4 lifecycle SYSTEM-STREAM plane (Slice I-iii) --------------------------------
223
+ /** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
224
+ * and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
225
+ * fold at RELEASE time ({@link foldSystemFrames}), never entering the sync layer or the local
226
+ * engine. The spec names which system table the qid serves and the scope/doc it was minted for
227
+ * (the fold's row filter). Empty on every non-lifecycle client — every partition below is then
228
+ * a structural no-op and the release path is byte-identical to before. */
229
+ systemQids = new Map();
230
+ /** The §4.2 fence state: room doc → highest `flush_seq` delivered through the daemon plane
231
+ * (monotone max-fold; a remove never regresses it). Slice I-v's ghost-drop consumer — I-iii
232
+ * only maintains + exposes it (`__inspectDomains().lifecycle`). */
233
+ roomWatermarks = new Map();
234
+ /** The §4.1 occupancy state: scope → (client_id → expires_at) from the doorbell stream. Slice
235
+ * I-iv's doorbell consumer (the 1→2 re-lease reaction) — I-iii only maintains + exposes it.
236
+ * A snapshot REPLACES the scope's map (authoritative re-hydrate); a batch folds add/edit/remove
237
+ * incrementally (the age-out sweep's deletes arrive as removes). */
238
+ scopeSessions = new Map();
239
+ /** The I-iv doorbell event sink ({@link onScopeSessions}) — fired once per scope a release's
240
+ * scope-session fold touched, AFTER the whole release applied. Default no-op: a client that
241
+ * never registers (no lifecycle plane) pays nothing. */
242
+ scopeSessionsHandler = () => { };
243
+ /** Deferred old-channel row GC for in-flight upgrade retargets ({@link retargetRemoteQuery}):
244
+ * sub sourceQid → the channel it left. The rows the OLD gate's sync holds for the qid stay
245
+ * visible (merge: daemon tier) until the sub's first snapshot RELEASES on its new room channel
246
+ * ({@link flushRetargetGc}) — dropping them at retarget time would emit net removes ahead of
247
+ * the room's re-adds, the flicker the two-phase cutover exists to avoid. Doubles as the
248
+ * wrong-channel GRACE window in {@link onFrame}: a frame already in flight from the old
249
+ * channel when the sub moved is stale, not a wiring bug. Empty on every non-upgrade client —
250
+ * every consultation below is then a structural no-op. */
251
+ pendingRetargetGc = new Map();
252
+ /** The §4.2 GHOSTS (Slice I-v): demoted room sources awaiting their watermark fence, by
253
+ * sourceKey. Written only by {@link demoteRoomSource}; evaluated after every release
254
+ * ({@link evaluateGhosts}) and dropped by {@link dropGhost} once the fence clears with no
255
+ * sent room-domain pending left. Empty on every non-downgrade client — the per-release
256
+ * evaluation is then a structural no-op. */
257
+ ghosts = new Map();
258
+ /** The I-v stuck-downgrade surface ({@link onDowngradeStuck}) — fired AT MOST ONCE per ghost
259
+ * when its fence is satisfied but sent room-domain mids remain unresolved (§7.5: they retire
260
+ * only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
261
+ * Default no-op. */
262
+ downgradeStuckHandler = () => { };
118
263
  asts = new Map();
119
264
  /** Per query: the base tables its result can draw from (from the AST tree). */
120
265
  queryTables = new Map();
@@ -132,15 +277,28 @@ export class OptimisticBackend {
132
277
  * touches its tables. Cached so `onPending` fires only on transitions (invoke ↔ confirm). */
133
278
  pendingState = new Map();
134
279
  pendingHandler = () => { };
280
+ /** The local-persistence write-through tap (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.1):
281
+ * {@link writeLocal} invokes it post-commit; {@link applyLocalReplica} deliberately does not. */
282
+ localWriteObserver = null;
135
283
  constructor(schema, source, registry, opts) {
136
284
  this.local = new WasmBackend(schema);
137
- this.local.onEvent((qid, ev) => this.handler(qid, ev));
285
+ this.local.onEvent((qid, ev) => {
286
+ // Stamp a newly-hydrating query's reconcile batch as a catch-up (initial-hydration) delivery,
287
+ // so the Store phases it as a `snapshot` rather than narrating the whole first result set. Record
288
+ // that we emitted a hydration batch for this qid, so `onProgress` knows which newly-hydrated qids
289
+ // still need an explicit empty catch-up (they folded nothing — see {@link catchUpEmitted}).
290
+ const stamp = ev.type === "batch" && this.catchUpQids?.has(qid) === true;
291
+ if (stamp)
292
+ this.catchUpEmitted?.add(qid);
293
+ this.handler(qid, stamp ? { ...ev, catchUp: true } : ev);
294
+ });
138
295
  // Forward the local engine's commit brackets up to the Store (cross-view-atomic notification):
139
296
  // every data delta this backend emits comes from `this.local`, so its commit boundaries are ours.
140
297
  this.local.onCommitBoundary((phase) => this.boundaryHandler(phase));
141
298
  this.colCounts = colCountsFromSchema(schema);
142
299
  this.colIndex = colIndexFromSchema(schema);
143
- this.sync = new NormalizedSync(pkColsFromSchema(schema), this.colCounts);
300
+ this.pkCols = pkColsFromSchema(schema);
301
+ this.sync = new NormalizedSync(this.pkCols, this.colCounts);
144
302
  this.specs = tableSpecsFromSchema(schema);
145
303
  this.localTables = localTableNames(schema);
146
304
  this.source = source;
@@ -149,21 +307,81 @@ export class OptimisticBackend {
149
307
  this.user = opts.user ?? (() => "");
150
308
  this.bufferCap = opts.bufferCap ?? 1024;
151
309
  this.clock = opts.clock ?? REAL_CLOCK;
152
- // Validate each server hello against our OWN typed schema reject a schema skew
153
- // (CRIT#4). The reserved lmid table is part of the expected set so the system
154
- // query's hello passes. Synthetic agg tables join the set as queries register them.
155
- this.clientTablesBase = [...normalizedTableSchemas(schema), CLIENT_MUTATIONS_SCHEMA];
156
- this.source.expectClientSchema?.(this.clientTablesBase);
157
- this.source.onNormalized((qid, ev) => this.onNormalized(qid, ev));
158
- this.source.onProgress((frame) => this.onProgress(frame));
159
- this.source.onRestart?.(() => this.resetForRestart());
160
- // The lmid system query (lmid-as-data): our confirmations arrive on this stream,
161
- // cv-tagged, released by the same cvMin as the data they belong to. The server
162
- // derives the identity from the connection; args are advisory.
163
- this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
310
+ // No policy configured every route DERIVES (H-iii §3). With no room gate connected the
311
+ // derivation short-circuits to "daemon", so a single-domain app is byte-for-byte as before.
312
+ this.domainPolicy = opts.domainPolicy ?? (() => undefined);
313
+ this.rejectedHandler = opts.onRejected ?? (() => { });
314
+ // The reserved lmid table + (I-iii) the four lifecycle system tables join the expected set so
315
+ // a system subscription's hello passes CRIT#4 validation. Extra CLIENT-side entries are inert
316
+ // for every other server hello (validation only checks tables a server advertises), so a
317
+ // client that never receives a lifecycle block is byte-identical.
318
+ this.clientTablesBase = [...normalizedTableSchemas(schema), CLIENT_MUTATIONS_SCHEMA, ...LIFECYCLE_TABLE_SCHEMAS];
319
+ // The daemon is the always-present channel: its gate is attached at construction, and its
320
+ // per-source refcount space IS `this.sync` (the agg overlay reads it directly). A room
321
+ // channel attaches through the same seam later (§5.1; Slice G).
322
+ this.daemonGate = this.attachGate("daemon", source, this.sync);
323
+ }
324
+ /** Wire one authority channel into its own coherence gate (§5.1): every frame the channel
325
+ * delivers buffers on THIS gate's cv timeline, its progress frames release THIS buffer, its
326
+ * restart resets THIS gate alone, and its reserved lmid stream folds into `watermark[key]`.
327
+ * Validates each server hello against our OWN typed schema → reject a schema skew (CRIT#4);
328
+ * the reserved lmid table is part of the expected set so the system query's hello passes, and
329
+ * synthetic agg tables join the set as queries register them. */
330
+ attachGate(key, source, sync) {
331
+ const gate = { key, source, sync, buffer: [], nextSeq: 0, appliedCv: 0 };
332
+ this.gates.set(key, gate);
333
+ source.expectClientSchema?.([...this.clientTablesBase, ...this.synthetic.values()]);
334
+ source.onNormalized((qid, ev) => this.onFrame(gate, qid, ev));
335
+ source.onProgress((frame) => this.onGateProgress(gate, frame));
336
+ source.onRestart?.(() => this.resetGate(gate));
337
+ // §301 §2.4: the DAEMON channel's observed boot-id stream seeds the client-local boot
338
+ // ordinals the direction-B fence's boot rule compares against (boot ids are opaque; order is
339
+ // the client's own observation). Only the daemon gate's boots matter — a room's advertised
340
+ // `upstreamBoot` names a DAEMON boot, and room-channel incarnations are handled by
341
+ // `resetGate`. Optional: an in-process source never reports one.
342
+ if (key === "daemon")
343
+ source.onBootId?.((bootId) => this.observeDaemonBoot(bootId));
344
+ // The deopt handshake's client half (H-v §3.3): the channel's `mutationOutcome` frames arrive
345
+ // as `(domain = gate.key, frame)`. OUT-OF-BAND — the source dispatches on arrival and this
346
+ // handler runs immediately, NEVER behind the gate's cv buffer: a deopt must migrate its entry
347
+ // BEFORE the buffered lmid release that would otherwise retire it as a success (and the §7.3
348
+ // hold-back trigger, keyed on `p.domain`, would park its staged writes the wrong way).
349
+ source.onMutationOutcome?.((frame) => this.handleMutationOutcome(gate.key, frame));
350
+ // §7.5 rule 3 (H-v): a re-established session re-sends this DOMAIN's unconfirmed pending
351
+ // envelopes with their ORIGINAL mids — the authority's own ledger dedups (an applied mid is
352
+ // silent; a non-applied one is re-answered from the recorded-outcome map into the handler
353
+ // above). This is the deopt crash-window closer: a frame lost with its socket is re-earned.
354
+ source.onResync?.(() => this.resendPending(gate.key));
355
+ // The lmid system query (lmid-as-data): confirmations arrive on this channel's stream,
356
+ // cv-tagged, released by the same cvMin as the data they belong to. The server derives
357
+ // the identity from the connection; args are advisory. Qid 0 is reserved PER CHANNEL —
358
+ // it never collides with Store-dealt qids and never enters the sync layer.
359
+ source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
360
+ return gate;
361
+ }
362
+ /** Attach a SECOND authority channel (§5.1) — the seam Slice G's room upgrade calls with the
363
+ * ws-backed room feed. Rooms speak the daemon protocol verbatim (§2.4: the client cannot tell
364
+ * a room from the daemon), so the argument is a full {@link OptimisticSource} — exactly what
365
+ * `@rindle/remote` builds from `{roomUrl, leaseToken}`. The channel buffers/releases on its
366
+ * own cv timeline (an independent §5.1 gate: coherent within, eventual across) and its
367
+ * reserved lmid stream folds into `watermark[sourceKey]` — so `sourceKey` must equal the
368
+ * `domainPolicy` name for the mutations this authority confirms. The converse is NOT required:
369
+ * a domain may exist with no connected gate (`__testRelease` drives confirms gate-less); the
370
+ * live production path stays daemon-only until G calls this. */
371
+ connectSource(sourceKey, source) {
372
+ if (this.gates.has(sourceKey)) {
373
+ throw new Error(`optimistic backend: source ${sourceKey} is already connected`);
374
+ }
375
+ this.attachGate(sourceKey, source, new NormalizedSync(this.pkCols, this.colCounts));
164
376
  }
165
377
  // --- the Backend seam ---------------------------------------------------------
166
- registerQuery(qid, ast, remote) {
378
+ /** `channel` (G-iii registration-time routing) names the authority channel the remote sub
379
+ * registers on — a `connectSource`d gate key; default `"daemon"` (every existing caller is
380
+ * byte-identical). Slice G-v threads the lease's `realtime.sourceKey` here. Validated FIRST
381
+ * (like the E3 check below): a bad channel must throw before any per-query state is recorded. */
382
+ registerQuery(qid, ast, remote, channel) {
383
+ if (remote)
384
+ this.requireGate(channel ?? "daemon");
167
385
  // queryTables is derived from the ORIGINAL ast — its `count(comments)` subquery names
168
386
  // `comment`, so an optimistic comment mutation flips this query to `unknown` (§6). The
169
387
  // local engine, by contrast, runs the REWRITTEN ast (reads the synthetic `__agg_*`).
@@ -196,7 +414,7 @@ export class OptimisticBackend {
196
414
  if (remote) {
197
415
  // A remote query is `unknown` until its first server snapshot lands (hydration); retainRemote
198
416
  // attaches it to the sub and sets the lifecycle against the sub's hydration state.
199
- this.retainRemote(qid, remote);
417
+ this.retainRemote(qid, remote, qid, channel);
200
418
  }
201
419
  else {
202
420
  // No server stream (a purely local AST view — or the local half of a split retain whose
@@ -267,10 +485,19 @@ export class OptimisticBackend {
267
485
  }
268
486
  unregisterQuery(qid) {
269
487
  const remoteQid = this.releaseRemote(qid);
270
- if (remoteQid !== undefined)
271
- this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
272
- // GC: rows this remote footprint SOLELY referenced fall to refcount 0 net removes.
273
- const gc = remoteQid === undefined ? [] : this.sync.dropQuery(remoteQid);
488
+ // GC: rows this remote footprint SOLELY referenced fall to refcount 0 → net removes. A qid
489
+ // lives on ONE channel, so at most one gate's dropQuery is non-empty (dropQuery of an
490
+ // unknown qid returns []) but sweep every gate so this needs no ownership lookup.
491
+ const gcs = [];
492
+ if (remoteQid !== undefined) {
493
+ this.pendingRetargetGc.delete(remoteQid); // the sweep below covers a mid-retarget teardown
494
+ for (const gate of this.gates.values()) {
495
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
496
+ const gc = gate.sync.dropQuery(remoteQid);
497
+ if (gc.length)
498
+ gcs.push([gate.key, gc]);
499
+ }
500
+ }
274
501
  // Tear down the local pipeline+view first so the reconcile cycle below skips it.
275
502
  this.local.unregisterQuery(qid);
276
503
  // The GC removals must leave BOTH head AND the engine's `sync` baseline. A plain
@@ -278,9 +505,10 @@ export class OptimisticBackend {
278
505
  // optimistic REMOVE: the next release's rewind diffs head against sync+D and RESURRECTS
279
506
  // them, GC never frees anything, and a later query is served the stale/deleted row
280
507
  // forever (CRIT#2). Deliver them as a coherent SERVER delta instead — the same
281
- // sync-moving boundary `onProgress` uses — so head and sync both drop the rows.
282
- if (gc.length)
283
- this.runReconcileCycle(gc);
508
+ // sync-moving boundary the release gate uses — so head and sync both drop the rows,
509
+ // against the SOURCE whose baseline held them.
510
+ for (const [key, gc] of gcs)
511
+ this.runReconcileCycle(key, gc);
284
512
  // The local pipeline is gone (no live conn) and the remote footprint's `__agg` rows were
285
513
  // GC'd above, so any synthetic table this was the last reader of can now be freed (§4).
286
514
  this.releaseSyntheticTables(qid);
@@ -291,10 +519,15 @@ export class OptimisticBackend {
291
519
  this.pendingState.delete(qid); // §7.2 cache, keyed by the local materialized qid (a monotonic
292
520
  // Store counter — re-materialize gets a fresh id, never this one again), so drop it on teardown.
293
521
  }
294
- retainRemoteQuery(qid, remote, localQueryId, ast) {
522
+ /** `channel` as in {@link registerQuery} (G-iii): the gate the remote sub registers on; default
523
+ * `"daemon"`. This is the split-retain seam G-v's resolve-then-register drives — resolve the
524
+ * lease, learn `realtime.sourceKey`, `connectSource` it, then retain the query on that channel.
525
+ * Validated FIRST so a bad channel throws before any synthetic-table refcount moves. */
526
+ retainRemoteQuery(qid, remote, localQueryId, ast, channel) {
527
+ this.requireGate(channel ?? "daemon");
295
528
  if (ast)
296
529
  this.ensureSyntheticTables(qid, ast);
297
- this.retainRemote(qid, remote, localQueryId);
530
+ this.retainRemote(qid, remote, localQueryId, channel);
298
531
  }
299
532
  releaseRemoteQuery(qid) {
300
533
  const remoteQid = this.releaseRemote(qid);
@@ -305,10 +538,307 @@ export class OptimisticBackend {
305
538
  }
306
539
  if (remoteQid === undefined)
307
540
  return;
308
- this.buffer = this.buffer.filter((f) => f.qid !== remoteQid);
309
- const gc = this.sync.dropQuery(remoteQid);
310
- if (gc.length)
311
- this.runReconcileCycle(gc);
541
+ // A mid-retarget release: the every-gate sweep below IS the deferred old-channel GC
542
+ // (dropQuery hits the old gate's sync too), so retire the pending record — and its
543
+ // wrong-channel grace — with it.
544
+ this.pendingRetargetGc.delete(remoteQid);
545
+ // Per-gate sweep, like `unregisterQuery`: at most one gate owned this qid's frames/rows.
546
+ for (const gate of this.gates.values()) {
547
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
548
+ const gc = gate.sync.dropQuery(remoteQid);
549
+ if (gc.length)
550
+ this.runReconcileCycle(gate.key, gc);
551
+ }
552
+ }
553
+ /** The Slice I-iv upgrade retarget (§4.1 "Retarget" / the doorbell reaction): move a LIVE
554
+ * (name, args) sub — every retain of it and every local view it feeds, wholesale — from the
555
+ * channel it lives on onto `sourceKey`'s (already-`connectSource`d, already-promoted) room
556
+ * channel, WITHOUT the view ever dropping its rows. Returns the sub's wire `sourceQid` (the
557
+ * identity the client's renewal loop re-subscribes with).
558
+ *
559
+ * Why a dedicated primitive: the one-channel-per-(name,args) invariant ({@link retainRemote}'s
560
+ * loud throw) is correct — a sub's frames must never split across two cv timelines — so the
561
+ * upgrade cannot simply retain a second sub on the room and release the daemon one; and the
562
+ * naive release-then-retain order GCs the daemon sync's rows synchronously (net removes emit,
563
+ * the view flashes empty) a full ws round trip before the room's seq-0 snapshot refills it.
564
+ * The cutover is therefore TWO-PHASE around the room's first release:
565
+ *
566
+ * 1. NOW (here): unsubscribe the old channel's wire sub, sweep its still-buffered frames for
567
+ * this qid (their cv timeline continues without the sub — the hello-supersession
568
+ * precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
569
+ * cutover point), and register on the room source (its resolver presents the handed
570
+ * roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
571
+ * rows visible through the window (merge: the daemon-tier holder). Local views keep their
572
+ * `hydrated` membership — the view stays `complete` on its authoritative daemon rows.
573
+ * 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT ({@link flushRetargetGc}): the room source now
574
+ * holds the footprint rows (folded via `serverBatchBegin(sourceKey)` — value-equal rows
575
+ * re-hydrate as a net-zero diff, RT §3.4 per channel), so the deferred
576
+ * `dropQuery`+reconcile on the OLD gate flips each pk's winner daemon→room value-equal:
577
+ * net-zero again. No emission carries a disappearance at any point.
578
+ *
579
+ * Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
580
+ * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey), mirroring
581
+ * {@link promoteRoomTable}'s caller-side per-(sourceKey, table) idempotence). Validates before
582
+ * mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
583
+ retargetRemoteQuery(remote, sourceKey) {
584
+ const newGate = this.requireGate(sourceKey); // throw loudly BEFORE any sub state moves
585
+ const key = remoteKey(remote);
586
+ const sub = this.remoteSubs.get(key);
587
+ if (!sub) {
588
+ throw new Error(`optimistic backend: no live sub for query "${remote.name}" — nothing to retarget`);
589
+ }
590
+ if (sub.channel === sourceKey)
591
+ return sub.sourceQid; // already there — idempotent
592
+ const oldGate = this.gates.get(sub.channel) ?? this.daemonGate;
593
+ oldGate.source.unregisterQuery(sub.sourceQid);
594
+ oldGate.buffer = oldGate.buffer.filter((f) => f.qid !== sub.sourceQid);
595
+ this.pendingRetargetGc.set(sub.sourceQid, sub.channel);
596
+ sub.channel = sourceKey;
597
+ sub.hydrated = false;
598
+ newGate.source.registerQuery(sub.sourceQid, remote);
599
+ return sub.sourceQid;
600
+ }
601
+ /** Phase 2 of {@link retargetRemoteQuery}, run at the end of every gate release: once a
602
+ * retargeted sub's first snapshot has RELEASED on its new channel (`sub.hydrated` re-armed at
603
+ * retarget, re-set by {@link markSubHydrated} inside this very release), drop the qid's rows
604
+ * from the OLD gate's sync and reconcile them out — after the room's rows are already applied,
605
+ * so the winner flip is value-equal (net-zero; see the phase table above). A sub torn down
606
+ * mid-window was already swept by `releaseRemoteQuery`/`unregisterQuery` (which delete the
607
+ * record); a vanished record here is pruned defensively. */
608
+ flushRetargetGc(gate) {
609
+ if (this.pendingRetargetGc.size === 0)
610
+ return; // every non-upgrade release: structural no-op
611
+ for (const [sourceQid, oldGateKey] of this.pendingRetargetGc) {
612
+ const key = this.sourceToRemote.get(sourceQid);
613
+ const sub = key !== undefined ? this.remoteSubs.get(key) : undefined;
614
+ if (!sub) {
615
+ this.pendingRetargetGc.delete(sourceQid);
616
+ continue;
617
+ }
618
+ if (sub.channel !== gate.key || !sub.hydrated)
619
+ continue; // not this gate / not yet cut over
620
+ this.pendingRetargetGc.delete(sourceQid);
621
+ const oldGate = this.gates.get(oldGateKey);
622
+ if (!oldGate)
623
+ continue;
624
+ const gc = oldGate.sync.dropQuery(sourceQid);
625
+ if (gc.length)
626
+ this.runReconcileCycle(oldGateKey, gc);
627
+ }
628
+ }
629
+ // --- the §4.2 downgrade: demote → ghost → fence → drop (Slice I-v) ----------------------
630
+ /** The I-v downgrade orchestration primitive (§4.2/§7.4): retire room `sourceKey` behind the
631
+ * watermark fence. The caller has ALREADY retargeted every live sub off the channel
632
+ * ({@link retargetRemoteQuery} room→daemon — validated loudly below) and holds the fence from
633
+ * the api-server's downgrade response (`finalFlushSeq` = the room's last COMMITTED flush seq;
634
+ * `doc` keys the §4.2 watermark fold, {@link roomWatermarks}). Steps, in order:
635
+ *
636
+ * 1. **De-candidacy NOW**: the sourceKey's {@link roomRouting} entries are removed, so
637
+ * {@link deriveDomain} stops proposing the room (zero candidates ⇒ `"daemon"`). Unsent
638
+ * pending (`mid === null`) re-routes for free — a fold's flush re-derives from the live
639
+ * candidate set (§7.5 rule 1). The engine's frozen scope still ANSWERS (`writableMatches`
640
+ * / `provenanceOf` — freezing gates serving, not the scope definition); routing is
641
+ * TS-gated here.
642
+ * 2. **Freeze** the WRITABLE promoted tables (`freezeSource`): the room slice becomes the
643
+ * §4.2 ghost — wins-if-present in the merge (D4), so its rows (at-or-ahead of the daemon
644
+ * until the flush echoes) stay visible while the slice accepts nothing. Context tables
645
+ * (`writable: false`) are deliberately NOT frozen: the daemon is authoritative for them
646
+ * LIVE (§5.2's context tier) and freezing would invert that, pinning a possibly-BEHIND
647
+ * relayed copy over fresher daemon rows; unfrozen they keep exactly the live tiering.
648
+ * 3. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
649
+ * dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
650
+ * an assigned mid pins its domain; a later re-upgrade of the same doc continues the
651
+ * sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
652
+ * into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
653
+ * load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
654
+ * gone ⇒ record deleted, nothing dropped) — running that GC would rewind the room slice's
655
+ * rows at daemon hydration, i.e. BEFORE the fence, surfacing a lagging follower's stale
656
+ * images (the exact regression §4.2 exists to prevent). The ghost's rows leave only
657
+ * through {@link dropGhost}'s `removeRoomSource`, value-equal under the fence.
658
+ * 4. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
659
+ * immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
660
+ * drops on the spot, the single-daemon first-frame case.
661
+ *
662
+ * In-flight discipline (§7.5): entries with `mid !== null` on `sourceKey` stay PINNED (rule
663
+ * 2 — never re-route a sent mutation); their resolution arrives via the daemon-carried
664
+ * ledger+outcome folds (I-iii) and blocks the drop until then. Idempotent per sourceKey (a
665
+ * second labeled query sharing the room demotes into the existing ghost). */
666
+ demoteRoomSource(sourceKey, doc, finalFlushSeq) {
667
+ if (sourceKey === "daemon") {
668
+ throw new Error("optimistic backend: the daemon source cannot be demoted");
669
+ }
670
+ if (this.ghosts.has(sourceKey))
671
+ return; // idempotent — one ghost per source
672
+ // Validate FIRST (nothing mutated yet): a live sub still on the channel would silently
673
+ // starve once the gate detaches — the caller must retarget every sub off the room first.
674
+ for (const sub of this.remoteSubs.values()) {
675
+ if (sub.channel === sourceKey) {
676
+ throw new Error(`optimistic backend: cannot demote ${JSON.stringify(sourceKey)} — query "${sub.remote.name}" is still retained on it (retarget it to the daemon first)`);
677
+ }
678
+ }
679
+ const routing = this.roomRouting.get(sourceKey);
680
+ const tables = routing ? [...routing.keys()] : [];
681
+ const writable = routing ? tables.filter((t) => routing.get(t).writable) : [];
682
+ this.roomRouting.delete(sourceKey); // (1) de-candidacy
683
+ for (const table of writable)
684
+ this.local.freezeSource(table, sourceKey); // (2) the ghost
685
+ this.disconnectSource(sourceKey); // (3) the channel
686
+ this.ghosts.set(sourceKey, { doc, finalFlushSeq, tables, stuckReported: false }); // (4)
687
+ this.evaluateGhosts();
688
+ }
689
+ /** Detach one connected room channel (Slice I-v step 3): the source's handlers are replaced
690
+ * with no-ops (the {@link OptimisticSource} handler seam is single-registration, so this IS
691
+ * the detach — a late frame from a dying socket can no longer touch any bookkeeping), its
692
+ * reserved lmid sub is unregistered, and the gate — buffer, per-source sync, cv watermark —
693
+ * is dropped from {@link gates}. The DOMAIN state deliberately survives forever:
694
+ * `nextMid[sourceKey]`, `watermark[sourceKey]`, and the processed-outcome set are untouched
695
+ * (§7.1 — an assigned mid pins its domain; a re-upgrade must continue, never restart, the mid
696
+ * sequence; {@link connectSource} then attaches a fresh gate whose lmid snapshot max-folds
697
+ * into the surviving watermark via {@link foldConfirm}). Closing the underlying transport is
698
+ * the caller's job. Idempotent (a missing gate is a no-op). */
699
+ disconnectSource(sourceKey) {
700
+ if (sourceKey === "daemon") {
701
+ throw new Error("optimistic backend: the daemon source cannot be disconnected");
702
+ }
703
+ const gate = this.gates.get(sourceKey);
704
+ if (!gate)
705
+ return;
706
+ this.gates.delete(sourceKey);
707
+ gate.source.onNormalized(() => { });
708
+ gate.source.onProgress(() => { });
709
+ gate.source.onRestart?.(() => { });
710
+ gate.source.onMutationOutcome?.(() => { });
711
+ gate.source.onResync?.(() => { });
712
+ gate.source.unregisterQuery(LMID_QID);
713
+ }
714
+ /** Register the I-v stuck-downgrade sink — see {@link DowngradeStuckEvent}. One handler (a
715
+ * later registration replaces it, the {@link onScopeSessions} convention); client.ts maps it
716
+ * onto the loud anomaly surface. */
717
+ onDowngradeStuck(handler) {
718
+ this.downgradeStuckHandler = handler;
719
+ }
720
+ /** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
721
+ * the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
722
+ * once at demote time. For each ghost: the fence must be satisfied
723
+ * (`roomWatermarks[doc] ≥ finalFlushSeq`; 0 is trivially satisfied) AND no SENT room-domain
724
+ * pending may remain (§7.5 — such entries resolve only through the daemon-carried
725
+ * outcome/ledger folds; an entry that never reached the room is undecidable, so the ghost
726
+ * HOLDS and the stuck event fires exactly once, naming the mids). Both satisfied ⇒
727
+ * {@link dropGhost}. */
728
+ evaluateGhosts() {
729
+ if (this.ghosts.size === 0)
730
+ return; // every non-downgrade release: structural no-op
731
+ for (const [sourceKey, ghost] of [...this.ghosts]) {
732
+ if ((this.roomWatermarks.get(ghost.doc) ?? 0) < ghost.finalFlushSeq)
733
+ continue; // fence holds
734
+ const stuck = this.pendingMutations.filter((p) => p.domain === sourceKey && p.mid !== null);
735
+ if (stuck.length > 0) {
736
+ if (!ghost.stuckReported) {
737
+ ghost.stuckReported = true;
738
+ this.downgradeStuckHandler({ sourceKey, doc: ghost.doc, mids: stuck.map((p) => p.mid) });
739
+ }
740
+ continue; // hold — never a timeout-retire (§7.5 rule 2)
741
+ }
742
+ this.dropGhost(sourceKey, ghost);
743
+ }
744
+ }
745
+ /** Drop one cleared ghost (§4.2 end state): detach every promoted table's room source (D2
746
+ * stay-merged — `removeRoomSource` reconciles each held pk to daemon-or-vanish, value-equal
747
+ * under the fence, so visually a no-op), release the {@link promotedTables} record for tables
748
+ * no OTHER room/ghost still holds, then run ONE daemon reconcile so entries whose writes had
749
+ * staged onto the ghost slice re-invoke and re-stage by provenance onto the daemon slice (the
750
+ * removal took their staged copies with the tree; provenance now answers daemon). The whole
751
+ * drop runs under one commit boundary so the removal's compensating deltas and the re-staged
752
+ * predictions notify as ONE step. After this, a FUTURE upgrade of the same doc promotes
753
+ * again from scratch — the round trip is pinned by the §8.5 lanes. */
754
+ dropGhost(sourceKey, ghost) {
755
+ this.ghosts.delete(sourceKey);
756
+ // Re-stage eligibility FIRST: a pending entry staged (only) on the ghost slice must join the
757
+ // daemon reconcile below (the cycle filter is `touchedSources.has(sourceKey)`) — union, never
758
+ // shrink, the §5.3 rule.
759
+ let restage = false;
760
+ for (const p of this.pendingMutations) {
761
+ if (p.touchedSources.has(sourceKey)) {
762
+ p.touchedSources.add("daemon");
763
+ restage = true;
764
+ }
765
+ }
766
+ this.inOneCommit(() => {
767
+ for (const table of ghost.tables)
768
+ this.local.removeRoomSource(table, sourceKey);
769
+ for (const table of ghost.tables) {
770
+ let held = false;
771
+ for (const byTable of this.roomRouting.values())
772
+ if (byTable.has(table))
773
+ held = true;
774
+ for (const g of this.ghosts.values())
775
+ if (g.tables.includes(table))
776
+ held = true;
777
+ if (!held)
778
+ this.promotedTables.delete(table);
779
+ }
780
+ // One daemon reconcile replays the re-staged entries onto the daemon slice. Run whenever
781
+ // any pending exists: the removal above may have taken a staged write with the tree even
782
+ // when `touchedSources` never recorded the ghost (defensive; an empty-pending drop skips).
783
+ if (restage || this.pendingMutations.length > 0)
784
+ this.runReconcileCycle("daemon", []);
785
+ });
786
+ this.refreshPending(); // the reconcile may have dropped a throwing re-invocation
787
+ }
788
+ // --- the §4 lifecycle SYSTEM-STREAM retains (Slice I-iii) ------------------------------
789
+ /** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
790
+ * I-iii): a wire sub with NO store view and NO user-visible table. Registered through the same
791
+ * {@link RemoteSub} bookkeeping as any remote retain — so qid→channel ownership, the overflow
792
+ * re-subscribe, and refcounted release all work unchanged — but with an EMPTY `localQids` set
793
+ * (no hydration/resultType coupling) and a {@link systemQids} record telling the release path
794
+ * which system table this qid's frames carry (`spec.table`) and which scope/doc it was minted
795
+ * for (the fold's row filter). Its frames then buffer on the channel's gate exactly like
796
+ * {@link LMID_QID}'s and fold at RELEASE time in {@link foldSystemFrames} — riding the SAME
797
+ * buffered cv path as the data they co-committed with (fence coherence: an out-of-band
798
+ * shortcut would break I-ii's co-commit ordering guarantee).
799
+ *
800
+ * `channel` defaults to `"daemon"` — the system tables live in the DAEMON store (that is the
801
+ * point: outcome/ledger/watermark rows must be readable with no room socket alive, §7.1
802
+ * "load-bearing for §7.5"). Idempotence per (table, scope/doc) is the CALLER's job (client.ts
803
+ * keys its retains on exactly that); a duplicate retain of the SAME remote identity refcounts
804
+ * like any sub. */
805
+ retainSystemQuery(retainQid, remote, spec, channel = "daemon") {
806
+ const gate = this.requireGate(channel); // throw loudly BEFORE any sub state moves
807
+ const key = remoteKey(remote);
808
+ let sub = this.remoteSubs.get(key);
809
+ if (sub) {
810
+ if (sub.channel !== channel) {
811
+ throw new Error(`optimistic backend: system query "${remote.name}" is already retained on channel ${JSON.stringify(sub.channel)} — cannot retain it on ${JSON.stringify(channel)}`);
812
+ }
813
+ sub.refCount++;
814
+ this.localToRemote.set(retainQid, key);
815
+ this.remoteRetainToLocal.set(retainQid, undefined);
816
+ return;
817
+ }
818
+ // A fresh sub: deliberately NOT `retainRemote` — its `localQueryId` default would couple this
819
+ // retain's qid to the view-hydration machinery (`hydrated`/`resultType`), and a system stream
820
+ // has no view to hydrate.
821
+ sub = { sourceQid: retainQid, remote, refCount: 1, localQids: new Map(), hydrated: false, channel };
822
+ this.remoteSubs.set(key, sub);
823
+ this.sourceToRemote.set(retainQid, key);
824
+ this.localToRemote.set(retainQid, key);
825
+ this.remoteRetainToLocal.set(retainQid, undefined);
826
+ this.systemQids.set(retainQid, { ...spec });
827
+ gate.source.registerQuery(retainQid, remote);
828
+ }
829
+ /** Release a {@link retainSystemQuery} retain. Refcounted like any sub; the LAST release
830
+ * unregisters from the owning channel, sweeps its buffered frames, and drops the
831
+ * {@link systemQids} record. The folded lifecycle STATE (`roomWatermarks`/`scopeSessions`/
832
+ * processed outcomes) deliberately survives — the fence is monotone truth about the store, not
833
+ * about the subscription (a re-retained fence must not forget a cleared watermark). */
834
+ releaseSystemQuery(retainQid) {
835
+ const remoteQid = this.releaseRemote(retainQid);
836
+ if (remoteQid === undefined)
837
+ return; // still refcounted (or unknown)
838
+ for (const gate of this.gates.values()) {
839
+ gate.buffer = gate.buffer.filter((f) => f.qid !== remoteQid);
840
+ }
841
+ this.systemQids.delete(remoteQid);
312
842
  }
313
843
  /** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
314
844
  * mutator — even a trivial one — and `invoke` it. */
@@ -319,9 +849,31 @@ export class OptimisticBackend {
319
849
  * straight to the local engine, OUTSIDE the optimistic pending stack — a local table is
320
850
  * untracked, so it never rebases, reverts, or waits on a confirmation. Rejects a synced/tracked
321
851
  * table (the local engine's `writeLocal` is the chokepoint). Reconcile is synchronous and
322
- * non-reentrant (A5), so a local write can never interleave with an open server cycle. */
852
+ * non-reentrant (A5), so a local write can never interleave with an open server cycle.
853
+ *
854
+ * Fires the {@link onLocalWrite} observer AFTER the engine commit but BEFORE subscriber
855
+ * delivery — i.e. for exactly the batches that passed the M2 guard and committed (the
856
+ * persistence layer's write-through tap, `207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.1). A
857
+ * subscriber throwing during delivery re-raises out of this call, but only after the tap has
858
+ * seen the batch: a committed write can never be invisible to the persistence plane. */
323
859
  writeLocal(mutations) {
324
- this.local.writeLocal(mutations);
860
+ this.local.writeLocal(mutations, () => this.localWriteObserver?.(mutations));
861
+ }
862
+ /** The write-through tap for the local-persistence layer (207 §5.1): `observer` sees every
863
+ * {@link writeLocal} batch post-commit. One observer (the layer); a later registration
864
+ * replaces it. The observer must not throw — a persistence failure degrades durability, never
865
+ * the write path (P9); the layer catches internally. */
866
+ onLocalWrite(observer) {
867
+ this.localWriteObserver = observer;
868
+ }
869
+ /** Apply a REPLICATED local batch (a restore snapshot / a leader `commit` — 207 §5.1): delegates
870
+ * to the engine's `writeLocal`, so the M2 locality guard still fires (P8 — a corrupt record
871
+ * naming a synced table dies loudly here), but does NOT invoke the {@link onLocalWrite}
872
+ * observer — the echo guard is structural, so the persistence layer can never re-enter itself.
873
+ * `onCommitted` fires post-commit pre-delivery (same anchor as {@link writeLocal}'s tap): the
874
+ * layer updates its mirror there, so a subscriber throw can never desync mirror from engine. */
875
+ applyLocalReplica(mutations, onCommitted) {
876
+ this.local.writeLocal(mutations, onCommitted);
325
877
  }
326
878
  onEvent(handler) {
327
879
  this.handler = handler;
@@ -366,10 +918,229 @@ export class OptimisticBackend {
366
918
  mutator(tx, args);
367
919
  }
368
920
  }
921
+ /** Deal the next wire mid from `domain`'s ledger (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
922
+ * §7.1) and advance that counter. A domain absent from the map starts at 1. Per-domain, so a
923
+ * client writing through room + daemon concurrently keeps two gapless, non-aliasing sequences.
924
+ * The client-global `seq` is stamped in the same breath — the ONE cross-domain total order
925
+ * (confirmation order is per-domain; replay order is client-global). Bundled here so no call
926
+ * site can deal a mid without its seq. ONE caller discards the seq deliberately: the H-v deopt
927
+ * flip ({@link handleMutationOutcome}) keeps the entry's ORIGINAL seq — its replay position —
928
+ * and takes only the fresh mid (the dealSeq bump is harmless: seq consumers order, never
929
+ * count). */
930
+ dealMid(domain) {
931
+ const mid = this.nextMid.get(domain) ?? 1;
932
+ this.nextMid.set(domain, mid + 1);
933
+ return { mid, seq: ++this.dealSeq };
934
+ }
935
+ // --- the §3 prove-or-slow-path ROUTER (H-iii) -----------------------------------
936
+ //
937
+ // RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3: a mutation routes to room R iff every row its
938
+ // prediction run WROTE is provably inside R's writable scope and every read it RECORDED is
939
+ // provably covered by R's footprint — ANY unproven condition routes slow (the §0 asymmetry:
940
+ // mis-guessing "slow" costs one round-trip; mis-guessing "room" would commit divergent data).
941
+ // Failure is therefore SILENT "daemon" — never a throw, never a warning per call — plus the Q6
942
+ // per-reason counters ({@link RoutingInspect}).
943
+ //
944
+ // The proof evaluates against the ENGINE wherever the engine has an answer: `writableMatches`
945
+ // (the registered compiled writable scope — the same predicate the merge's winner tiering uses)
946
+ // and `provenanceOf` (the visible overlay-first winner). It runs AFTER the prediction committed,
947
+ // so the probes see the prediction's own staged effects — which only ever strengthens the
948
+ // conservative direction (a fresh pk reports "daemon"/undefined and neither disqualifies).
949
+ //
950
+ // ** THE ONE CLIENT-SIDE NON-ENGINE EVALUATOR — deliberately minimal (the TS evaluation
951
+ // caveat). ** Evaluating `footprintWhere` on a read's pk cells ({@link evalFootprintOnPk}) is
952
+ // the single place Slice H allows a non-engine evaluator, and it is restricted to STRICT cell
953
+ // equality only: `simple` `=` / `!=` between a pk column and a same-primitive-type non-null
954
+ // literal, composed under and/or (the empty AND is the vacuous-true exact-unconstrained-root
955
+ // emission and evaluates true). ANY other op or shape — LIKE, <, >=, IS, IN, null operands,
956
+ // cross-type comparisons, column-vs-column — is NOT EVALUABLE and fails to the daemon.
957
+ // Justification: a mis-evaluation here cannot commit divergence — the room GATE (H-iv) re-proves
958
+ // every write and absent read engine-side at commit and deopts, so a wrong client verdict only
959
+ // costs a wasted hop (route room → gate deopt) or a skipped optimization (route daemon); and
960
+ // restricting to strict equality on non-null, same-typed pk cells removes the entire
961
+ // null/collation/coercion divergence space between this evaluator and the engine's comparators.
962
+ // The gate remains the contract; this router is the optimization.
963
+ //
964
+ // ** H-v (SHIPPED — the router's missing recovery half). ** A derived (or pinned) room route
965
+ // whose gate DEOPTS (the H-iv-b `mutationOutcome {kind:"deopt"}` frame — the mid is burnt in
966
+ // the room ledger, no effects committed) is re-enqueued onto the daemon stream by
967
+ // {@link handleMutationOutcome}: the entry flips in place (fresh daemon mid, ORIGINAL seq,
968
+ // prediction applied throughout), or — already retired — re-invokes from the frame's echoed
969
+ // name/args pinned to the daemon. A reconnect re-sends unconfirmed mids so a frame lost with
970
+ // its socket is re-answered ({@link resendPending}). A wrong client verdict therefore costs
971
+ // exactly one room round-trip plus one burnt room-mid (§3.3), never a stranded prediction.
972
+ /** The route decision for one invocation (invoke, and again at fold flush): the explicit
973
+ * `domainPolicy` is the OVERRIDE — a string pins that domain verbatim, no proof runs;
974
+ * `undefined` (or no policy configured) derives per §3. */
975
+ resolveDomain(name, args, writes, reads) {
976
+ const pinned = this.domainPolicy(name, args);
977
+ if (pinned !== undefined)
978
+ return pinned;
979
+ return this.deriveDomain(writes, reads);
980
+ }
981
+ /** §9.3: does this fold's write-set route into a room (vs the daemon)? Used ONLY to pick the
982
+ * {@link FoldOptions.roomDebounceMs} cadence at a fold window's start. It probes
983
+ * {@link resolveDomain} but RESTORES the Q6 routing diagnostics afterward — the authoritative
984
+ * route and its single counter bump belong to the flush ({@link flushFold}), not to this
985
+ * interval hint. A fold's reads are provably empty (the read-trap arms on that path), so the
986
+ * write-set alone decides. */
987
+ routesToRoom(name, args, writes) {
988
+ const reasons = [...this.routingReasons];
989
+ const derived = [...this.routingDerived];
990
+ const domain = this.resolveDomain(name, args, writes, { reads: [], queries: [] });
991
+ this.routingReasons.clear();
992
+ for (const [k, v] of reasons)
993
+ this.routingReasons.set(k, v);
994
+ this.routingDerived.clear();
995
+ for (const [k, v] of derived)
996
+ this.routingDerived.set(k, v);
997
+ return domain !== "daemon";
998
+ }
999
+ /** Count one derivation failure ({@link RoutingInspect}). Returns `false` so the per-candidate
1000
+ * proof's call sites read `return this.failDerivation(...)`. */
1001
+ failDerivation(reason) {
1002
+ this.routingReasons.set(reason, (this.routingReasons.get(reason) ?? 0) + 1);
1003
+ return false;
1004
+ }
1005
+ /** The §3 derivation. Candidates are the connected room gates with at least one promoted table
1006
+ * (a promoted table with no gate cannot confirm; a gate with no promoted table holds no data to
1007
+ * prove against). Zero candidates ⇒ `"daemon"` (the single-domain fast path). The proof runs
1008
+ * per candidate and EXACTLY ONE must survive — two rooms both proving routes slow (principled
1009
+ * disambiguation is a §9.2 multi-room NON-goal, deferred past Slice J; slow is always sound). */
1010
+ deriveDomain(writes, reads) {
1011
+ const candidates = [];
1012
+ for (const key of this.gates.keys()) {
1013
+ if (key === "daemon")
1014
+ continue;
1015
+ if ((this.roomRouting.get(key)?.size ?? 0) > 0)
1016
+ candidates.push(key);
1017
+ }
1018
+ if (candidates.length === 0) {
1019
+ this.failDerivation("no-candidates");
1020
+ return "daemon";
1021
+ }
1022
+ // tx.query is not room-executable client-side: predicate containment (rindle-cover) is
1023
+ // native-only BY DESIGN, so a declarative read fails the WHOLE derivation unconditionally.
1024
+ // This is required, not conservative.
1025
+ if (reads.queries.length > 0) {
1026
+ this.failDerivation("tx-query");
1027
+ return "daemon";
1028
+ }
1029
+ const proven = candidates.filter((room) => this.provesRoom(room, writes, reads));
1030
+ if (proven.length === 1) {
1031
+ const room = proven[0];
1032
+ this.routingDerived.set(room, (this.routingDerived.get(room) ?? 0) + 1);
1033
+ return room;
1034
+ }
1035
+ if (proven.length > 1)
1036
+ this.failDerivation("ambiguous");
1037
+ return "daemon"; // zero survivors: each candidate already counted its own failure reason
1038
+ }
1039
+ /** One candidate room's §3 proof over the captured write-set + read-log. Every write must pass
1040
+ * ALL the write rules; every read must pass ONE of the read rules. First failure wins (and is
1041
+ * counted); order is deterministic (write-set map order, then read-log order). */
1042
+ provesRoom(room, writes, reads) {
1043
+ const specs = this.roomRouting.get(room);
1044
+ // --- write rules: every WriteRecord passes ALL of #1–#4 -------------------------------
1045
+ for (const [table, byPk] of writes) {
1046
+ // #1: the table is promoted for R with a writable (predicate-kind) spec. A context table
1047
+ // (`writable: none`), an un-promoted table, and a local table (unreachable — the mutator
1048
+ // guard refuses local writes at stage time) all fail here.
1049
+ const spec = specs?.get(table);
1050
+ if (spec === undefined || !spec.writable)
1051
+ return this.failDerivation("write-unwritable-table");
1052
+ const colIx = this.colIndex[table];
1053
+ for (const rec of byPk.values()) {
1054
+ const removeShape = rec.row === undefined;
1055
+ // #2: the registered writable scope, evaluated BY THE ENGINE on the post-image for
1056
+ // add/edit shapes and on the pre-image for removes (the row the room would delete). A
1057
+ // remove record always carries its full-width pre-image (the H-ii capture contract);
1058
+ // a violated contract fails closed rather than probing nothing.
1059
+ const scopeRow = removeShape ? rec.oldRow : rec.row;
1060
+ if (scopeRow === undefined || !this.local.writableMatches(table, room, scopeRow)) {
1061
+ return this.failDerivation("write-scope-miss");
1062
+ }
1063
+ // #3: join-key no-change. An edit-shape (row + oldRow — the oldRow is the txn-entry base)
1064
+ // must keep every joinKeyCols cell STRICTLY identical old-vs-new; an add-shape may SET
1065
+ // them (it creates the correlation); removes are exempt. A join-key column missing from
1066
+ // the schema is a spec bug — fail closed.
1067
+ if (!removeShape && rec.oldRow !== undefined) {
1068
+ for (const col of spec.joinKeyCols) {
1069
+ const i = colIx?.get(col);
1070
+ if (i === undefined || !identicalCell(rec.oldRow[i], rec.row[i])) {
1071
+ return this.failDerivation("write-join-key-change");
1072
+ }
1073
+ }
1074
+ }
1075
+ // #4: provenance corroboration — the engine routes an Edit by its OLD row (H-i), so probe
1076
+ // the pre-image for edit/remove shapes and the post-image for add-shapes. A winner naming
1077
+ // a DIFFERENT room disqualifies; "daemon"/undefined does NOT (a thin slice, or a fresh pk
1078
+ // — fresh pks stage to the daemon slice by the E2 decision, kept permanently: the
1079
+ // room-side authoritative run is the committing execution either way).
1080
+ const probeRow = rec.oldRow ?? rec.row;
1081
+ if (probeRow !== undefined) {
1082
+ const src = this.local.provenanceOf(table, probeRow);
1083
+ if (src !== undefined && src !== "daemon" && src !== room) {
1084
+ return this.failDerivation("write-cross-room-provenance");
1085
+ }
1086
+ }
1087
+ }
1088
+ }
1089
+ // --- read rules: every ReadRecord passes ONE ------------------------------------------
1090
+ // (tx.query already failed the whole derivation in deriveDomain — never reaches here.)
1091
+ for (const r of reads.reads) {
1092
+ // Self-read: the pk is in THIS invocation's write-set for that table — the write rules
1093
+ // above already judged it (covers the keyed writers' probe-then-write shape). NOTE the
1094
+ // read-log reflects the ORIGINAL invoke only (the pre-existing capture caveat).
1095
+ if (writes.get(r.table)?.has(stableJson(r.pk)))
1096
+ continue;
1097
+ if (r.source !== undefined) {
1098
+ if (r.source === room)
1099
+ continue; // the room served the row — in-footprint by construction
1100
+ if (r.source !== "daemon")
1101
+ return this.failDerivation("read-cross-room");
1102
+ }
1103
+ // Present-with-daemon/undefined source, or ABSENT (source is never recorded for those —
1104
+ // including the un-promoted-table meaning of an absent key): the footprint-membership test
1105
+ // on the PK ALONE. R's footprintWhere must exist, be key-decidable (every column it reads is
1106
+ // a pk column), and evaluate TRUE on the read's pk cells:
1107
+ // present-daemon + TRUE ⇒ the row is in the room's COMPLETE footprint ⇒ covered;
1108
+ // absent + TRUE ⇒ absent-in-room = absent-in-truth ⇒ covered;
1109
+ // FALSE (either outcome) ⇒ fail — present-daemon-FALSE means the room lacks the row;
1110
+ // absent-FALSE is arguably provable (decidably-outside ⇒ the room never sees the pk)
1111
+ // but the room-side run would then read absent for a DIFFERENT reason than truth's —
1112
+ // stay CONSERVATIVE and fail;
1113
+ // no footprintWhere / not key-decidable / not evaluable ⇒ fail (see the evaluator caveat
1114
+ // on the router block comment).
1115
+ const fw = specs?.get(r.table)?.footprintWhere;
1116
+ if (fw === undefined)
1117
+ return this.failDerivation("read-no-footprint-where");
1118
+ const tspec = this.specs[r.table];
1119
+ const pkCols = tspec.primaryKey.map((i) => tspec.columns[i]);
1120
+ if (!keyDecidable(fw, new Set(pkCols)))
1121
+ return this.failDerivation("read-not-key-decidable");
1122
+ const pkCells = new Map(pkCols.map((c, j) => [c, r.pk[j]]));
1123
+ const verdict = evalFootprintOnPk(fw, pkCells);
1124
+ if (verdict === undefined)
1125
+ return this.failDerivation("read-not-evaluable");
1126
+ if (!verdict)
1127
+ return this.failDerivation("read-outside-footprint");
1128
+ }
1129
+ return true;
1130
+ }
369
1131
  /** Run the named client mutator optimistically: the prediction applies to the live
370
1132
  * engine now (affected views update synchronously), `(mid, name, args)` joins the
371
1133
  * pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
372
1134
  invoke(name, args) {
1135
+ return this.invokeWith(name, args);
1136
+ }
1137
+ /** {@link invoke} with an optional PINNED confirming domain (H-v): the deopt handshake's
1138
+ * already-retired arm re-invokes the frame's echoed `(name, args)` as a FRESH invocation pinned
1139
+ * to `"daemon"` — an honest re-prediction on the current base, never derived (`pin` bypasses
1140
+ * {@link resolveDomain} entirely, so the router never runs and no Q6 counter moves). Every
1141
+ * other step is `invoke` verbatim: prediction now, capture, drainOverlapping, mid dealt from
1142
+ * the pinned domain's ledger, envelope on its channel. */
1143
+ invokeWith(name, args, pin) {
373
1144
  const mutator = this.registry[name];
374
1145
  if (!mutator)
375
1146
  throw new Error(`unknown client mutator: ${name}`);
@@ -380,18 +1151,39 @@ export class OptimisticBackend {
380
1151
  // the staged write is discarded (the wasm txn is a clean no-op until commit) and the throw
381
1152
  // propagates with NO mid consumed — a burnt mid is a permanent server-side gap that
382
1153
  // silently refuses every later mutation from this client (#10).
383
- const touched = new Set();
1154
+ const writes = new Map();
1155
+ const reads = { reads: [], queries: [] };
384
1156
  const ops = [];
385
- this.local.writeWith((tx) => {
386
- this.runMutator(mutator, trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), args);
1157
+ // Per-staged-write source keys (staged order), zipped with the `commitTracked` `sources[]`
1158
+ // below to learn which physical source each pk routed onto (§5.3 filter + §7.3 hold-back).
1159
+ const stagedKeys = [];
1160
+ const sources = this.local.writeWith((tx) => {
1161
+ this.runMutator(mutator,
1162
+ // `readProvenance` rides only with recording (H-ii §3.2 #3): the folded path (trap, no
1163
+ // readLog) and the reconcile replay (no readLog) never probe — see trackingTx.
1164
+ trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, reads, stagedKeys, this.readProvenance), args);
387
1165
  });
1166
+ // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1167
+ const touched = new Set(writes.keys());
388
1168
  // Flush-on-enqueue (§4.2): a fold whose tables overlap this write must take its mid NOW, BEFORE
389
1169
  // this write does, so wire order == local-apply order for any pair that can observe each other
390
1170
  // (a read-dependent write reading a folded cell sees the same value optimistically and on the
391
1171
  // wire — no snap). Drained folds ship with smaller mids; this write's mid is dealt after.
392
1172
  this.drainOverlapping(touched);
393
- const mid = this.nextMid++;
394
- this.pendingMutations.push({ mid, name, args, touched });
1173
+ // The confirming stream (§7.1): its ledger deals the mid and its watermark alone retires the
1174
+ // entry. THE §3 ROUTER RUNS HERE (H-iii) after the prediction (write/read capture is
1175
+ // complete, and the engine holds the committed prediction the probes read) and BEFORE the
1176
+ // mid is dealt: an assigned mid pins its domain forever (§7.1) — a re-invocation never
1177
+ // re-routes. An explicit `domainPolicy` string pins verbatim (no proof); an H-v deopt
1178
+ // re-invocation pins via `pin` (see {@link invokeWith}) and the router never runs.
1179
+ const domain = pin ?? this.resolveDomain(name, args, writes, reads);
1180
+ const { mid, seq } = this.dealMid(domain);
1181
+ // The write-source axes (§5.3): which physical source each write staged onto. All `"daemon"`
1182
+ // in single-domain (Collapsed) — so the filter and hold-back below are inert on the live path.
1183
+ const touchedSources = new Set(sources);
1184
+ const writeSources = new Map();
1185
+ mergeWriteSources(writeSources, stagedKeys, sources);
1186
+ this.pendingMutations.push({ mid, seq, name, args, domain, touched, writes, reads, touchedSources, writeSources });
395
1187
  // The prediction stuck — fold its child ops into the optimistic agg delta and push it onto
396
1188
  // the `__agg` head rows (§4). No reset here (this is the §1.3 trivial case, no rewind): the
397
1189
  // delta accumulates on top of the prior pending set, and `reconcileAggHead` recomputes each
@@ -400,7 +1192,7 @@ export class OptimisticBackend {
400
1192
  this.overlay.observe(op);
401
1193
  this.reconcileAggHead();
402
1194
  this.refreshPending(); // §7.2: this write now touches its queries' pending axis (NOT ResultType).
403
- void this.source.pushMutation({ clientID: this.clientID, mid, name, args });
1195
+ void this.channelFor(domain).pushMutation({ clientID: this.clientID, mid, name, args });
404
1196
  return mid;
405
1197
  });
406
1198
  }
@@ -418,12 +1210,15 @@ export class OptimisticBackend {
418
1210
  return this.inOneCommit(() => {
419
1211
  // Apply the prediction with the read trap armed (§5): a folded mutator that reads state to
420
1212
  // compute its write is non-absorbing and refused. A throw discards the staged write (clean
421
- // no-op) and consumes no mid — exactly `invoke`'s guarantee.
422
- const touched = new Set();
1213
+ // no-op) and consumes no mid — exactly `invoke`'s guarantee. NO `readLog` here — the trap
1214
+ // path stays byte-for-byte as it was; recording (§3.2 #2) never arms alongside the trap.
1215
+ const writes = new Map();
423
1216
  const ops = [];
1217
+ const stagedKeys = [];
1218
+ let sources = [];
424
1219
  try {
425
- this.local.writeWith((tx) => {
426
- this.runMutator(mutator, trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops), true), args);
1220
+ sources = this.local.writeWith((tx) => {
1221
+ this.runMutator(mutator, trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), true, undefined, stagedKeys), args);
427
1222
  });
428
1223
  }
429
1224
  catch (e) {
@@ -435,6 +1230,13 @@ export class OptimisticBackend {
435
1230
  for (const op of ops)
436
1231
  this.overlay.observe(op);
437
1232
  this.reconcileAggHead();
1233
+ // `touched` is DERIVED, never separately populated (§3.2 #1) — see {@link WriteSet}.
1234
+ const touched = new Set(writes.keys());
1235
+ // The write-source axes (§5.3), like `touched`/`writes` — REPLACED wholesale on an in-place
1236
+ // fold overwrite (the latest invocation supersedes, absorbing), initialized on a new entry.
1237
+ const touchedSources = new Set(sources);
1238
+ const writeSources = new Map();
1239
+ mergeWriteSources(writeSources, stagedKeys, sources);
438
1240
  const now = this.clock.now();
439
1241
  let f = this.folds.get(foldKey);
440
1242
  if (f) {
@@ -443,11 +1245,25 @@ export class OptimisticBackend {
443
1245
  // only the LATEST args, which is what a rebase re-derives from and what the flush ships.
444
1246
  f.entry.args = args;
445
1247
  f.entry.touched = touched;
1248
+ f.entry.writes = writes;
1249
+ f.entry.touchedSources = touchedSources;
1250
+ f.entry.writeSources = writeSources;
446
1251
  f.args = args;
447
1252
  this.clock.clearTimeout(f.timer);
448
1253
  }
449
1254
  else {
450
- const entry = { mid: null, name, args, touched };
1255
+ // Provisional domain (§7.1): re-resolved from the final args/write-set at flush, when the
1256
+ // mid is dealt. Never read before then — an un-flushed fold (`mid == null`) is
1257
+ // unconditionally retained — so the full (counting) derivation deliberately does NOT run
1258
+ // here (it would double-bump the Q6 counters for one logical route); the flush derives.
1259
+ const domain = this.domainPolicy(name, args) ?? "daemon";
1260
+ // §9.3: pick the window's cadence. Routing into a room ⇒ flush at roomDebounceMs so
1261
+ // intermediates stream to the shared head; off the room, the caller's collapse debounce
1262
+ // governs. The probe restores the Q6 diagnostics (the flush owns the authoritative bump).
1263
+ const inRoom = opts.roomDebounceMs !== undefined && this.routesToRoom(name, args, writes);
1264
+ const debounceMs = inRoom ? opts.roomDebounceMs : opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS;
1265
+ const maxWaitMs = inRoom ? opts.roomDebounceMs : opts.maxWaitMs;
1266
+ const entry = { mid: null, seq: null, name, args, domain, touched, writes, reads: { reads: [], queries: [] }, touchedSources, writeSources };
451
1267
  this.pendingMutations.push(entry);
452
1268
  let resolveMid;
453
1269
  const midPromise = new Promise((res) => (resolveMid = res));
@@ -456,8 +1272,8 @@ export class OptimisticBackend {
456
1272
  args,
457
1273
  timer: undefined,
458
1274
  firstAt: now,
459
- debounceMs: opts.debounceMs ?? DEFAULT_FOLD_DEBOUNCE_MS,
460
- maxWaitMs: opts.maxWaitMs,
1275
+ debounceMs,
1276
+ maxWaitMs,
461
1277
  deferAcrossWrites: opts.deferAcrossWrites ?? false,
462
1278
  midPromise,
463
1279
  resolveMid,
@@ -499,11 +1315,166 @@ export class OptimisticBackend {
499
1315
  return;
500
1316
  this.clock.clearTimeout(f.timer);
501
1317
  this.folds.delete(foldKey);
502
- const mid = this.nextMid++;
1318
+ // Resolve the confirming stream from the FINAL args (§7.1) and deal the mid from that domain's
1319
+ // ledger — SEND order, never reserved, so gapless within the domain. Under H-iii derivation
1320
+ // this RE-DERIVES from the entry's LATEST write-set (each in-place fold overwrite replaced it,
1321
+ // so it reflects the final absorbed value): same §3 rules minus reads — a fold's read-set is
1322
+ // provably empty by construction (the FoldReadError trap, not recording, arms on that path),
1323
+ // which is the STRONGEST read proof there is. The mid dealt below then pins this domain.
1324
+ const domain = this.resolveDomain(f.entry.name, f.args, f.entry.writes, f.entry.reads);
1325
+ f.entry.domain = domain;
1326
+ const { mid, seq } = this.dealMid(domain);
503
1327
  f.entry.mid = mid;
504
- void this.source.pushMutation({ clientID: this.clientID, mid, name: f.entry.name, args: f.args });
1328
+ f.entry.seq = seq;
1329
+ void this.channelFor(domain).pushMutation({ clientID: this.clientID, mid, name: f.entry.name, args: f.args });
505
1330
  f.resolveMid(mid);
506
1331
  }
1332
+ /** The transport a `domain`-confirmed mutation ships on (§7.5 sent-pins-domain: only the
1333
+ * domain's own authority can confirm it, so its channel is the only correct transport). A
1334
+ * domain with NO connected gate ships on the daemon channel — the gate-less configurations
1335
+ * (`__testRelease`-driven tests) and today's entire live path resolve `"daemon"` anyway. */
1336
+ channelFor(domain) {
1337
+ return (this.gates.get(domain) ?? this.daemonGate).source;
1338
+ }
1339
+ /** The gate a channel-keyed retain registers through (G-iii registration-time routing). The
1340
+ * channel MUST already be connected (`connectSource`; the daemon is constructor-attached) —
1341
+ * loud by design: a typo'd or not-yet-connected sourceKey must throw at retain time, never
1342
+ * silently register on the daemon and split the query's frames across channels. */
1343
+ requireGate(channel) {
1344
+ const gate = this.gates.get(channel);
1345
+ if (!gate) {
1346
+ throw new Error(`optimistic backend: no source connected for channel ${JSON.stringify(channel)} — call connectSource(${JSON.stringify(channel)}, source) before retaining a query on it`);
1347
+ }
1348
+ return gate;
1349
+ }
1350
+ /** The channel that owns `sourceQid` — {@link RemoteSub.channel}, the ONE source of truth for
1351
+ * qid routing (G-iii). `undefined` when no sub owns the qid (a harness-delivered raw feed, or
1352
+ * a just-released sub): such frames buffer on whatever gate they arrive at. */
1353
+ channelOf(sourceQid) {
1354
+ const key = this.sourceToRemote.get(sourceQid);
1355
+ return key ? this.remoteSubs.get(key)?.channel : undefined;
1356
+ }
1357
+ // --- the §3.3 deopt handshake, client half (H-v) ---------------------------------
1358
+ //
1359
+ // THE NAMED INVARIANT (Slice I inherits it): **never retire a room-domain entry off a
1360
+ // daemon-carried lmid without outcome resolution.** On the room socket it holds by
1361
+ // construction: every room lmid folds through the room's OWN gate, whose socket also carries
1362
+ // the outcome frames — same-socket ordering puts the frame before the ack, and the reconnect
1363
+ // re-send re-earns a lost frame, so a room-domain entry is only ever retired as a success when
1364
+ // the room really applied it. Slice I's downgrade path breaks that coupling: the doc-scoped
1365
+ // ledger row becomes readable THROUGH THE DAEMON with no room socket alive (§7.1 "load-bearing
1366
+ // for §7.5"), and an lmid adopted that way covers burnt non-applied mids with no frame to say
1367
+ // so — retiring a deopted entry there as a silent success is exactly the lost-write this
1368
+ // handshake exists to prevent. ENFORCED since I-iii by {@link foldSystemFrames}: the I-ii
1369
+ // outcome ROWS (co-committed, in ONE daemon transaction, with the ledger row that covers them)
1370
+ // are synthesized into frames and routed through THIS machine BEFORE the ledger fold advances
1371
+ // the domain watermark — one verdict path for frames and rows, with the processed set as the
1372
+ // cross-release resolved-verdict memory, and absence-under-a-covering-lmid = applied (I-ii's
1373
+ // atomicity makes that the sound default).
1374
+ /** Record `(domain, mid)` as processed; `false` if it already was (a duplicate frame —
1375
+ * ignore it). FIFO-capped per domain ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}). */
1376
+ markOutcomeProcessed(domain, mid) {
1377
+ let mids = this.outcomesProcessed.get(domain);
1378
+ if (!mids)
1379
+ this.outcomesProcessed.set(domain, (mids = new Set()));
1380
+ if (mids.has(mid))
1381
+ return false;
1382
+ mids.add(mid);
1383
+ while (mids.size > MAX_PROCESSED_OUTCOMES_PER_DOMAIN) {
1384
+ mids.delete(mids.values().next().value);
1385
+ }
1386
+ return true;
1387
+ }
1388
+ /** One `mutationOutcome` frame from `domain`'s channel (H-v — the §3.3 handshake's client
1389
+ * half). The frame arrives OUT-OF-BAND (see {@link attachGate}); the state machine:
1390
+ *
1391
+ * 1. `mid` never issued on `domain` ⇒ ignore (a confused/foreign frame must not invent work).
1392
+ * 2. `(domain, mid)` already processed ⇒ ignore — idempotence under duplicate frames (the
1393
+ * original + a re-send's re-answer; a deopt for a mid whose entry ALREADY FLIPPED also
1394
+ * lands here harmlessly on its second frame).
1395
+ * 3. `kind:"rejected"` ⇒ FINAL. Surface the reason through {@link rejectedHandler} (room-plane
1396
+ * parity with the HTTP queue's callback) and STOP — the drop + snap-back is the EXISTING
1397
+ * failed-mutation machinery: the room burnt the mid, its lmid release retires the entry
1398
+ * per-domain and the reconcile rewinds the prediction, exactly the daemon path's
1399
+ * processed-as-no-op rejection. No new drop path.
1400
+ * 4. `kind:"deopt"`, entry found (pending `(domain, mid)`) ⇒ FLIP IN PLACE: `domain` becomes
1401
+ * `"daemon"`, a fresh daemon mid is dealt and the envelope ships NOW on the daemon channel
1402
+ * ("deal-and-send-now" — the conforming §3.3 re-enqueue: there is no flush machinery for
1403
+ * non-fold entries, so the design's "mid: null until the daemon flush" is satisfied
1404
+ * momentarily inside this call). THE ENTRY'S `seq` IS KEPT — settled (§5.3, commit
1405
+ * 68141096): `seq` is the client-global REPLAY order; re-sequencing would move the entry's
1406
+ * overlay position and change read-dependent SIBLINGS' replay base. Everything else stays
1407
+ * (writes/reads/touched/touchedSources/writeSources — union-never-shrink), the prediction
1408
+ * stays applied (the entry never leaves `pendingMutations`, so no rewind fires), and the
1409
+ * router does NOT re-run nor does `drainOverlapping` (§3.3 re-enqueues, never re-derives;
1410
+ * any open overlapping fold was invoked later and flushes later with a larger mid).
1411
+ * 5. `kind:"deopt"`, entry NOT found ⇒ the burnt-mid confirm won the race, or the frame is a
1412
+ * replay re-answer for an entry a previous session retired (the replay gotcha): re-invoke
1413
+ * the frame's echoed `name`/`args` as a FRESH invocation PINNED to `"daemon"` — an honest
1414
+ * re-prediction on the current base, never a derived route ({@link invokeWith}). A frame
1415
+ * without `name` (not self-contained) has nothing to re-invoke and is dropped; a re-invoke
1416
+ * that THROWS (the base moved from under it) is surfaced through {@link rejectedHandler} —
1417
+ * the mutation is dead with no stream left to confirm it.
1418
+ *
1419
+ * A `"deopt"` bump joins the Q6 routing counters either way (`routing.reasons.deopt`) —
1420
+ * derived-and-deopted routes are visible beside derived successes. */
1421
+ handleMutationOutcome(domain, frame) {
1422
+ if (frame.mid >= (this.nextMid.get(domain) ?? 1))
1423
+ return; // never issued here — not ours
1424
+ if (!this.markOutcomeProcessed(domain, frame.mid))
1425
+ return; // duplicate frame
1426
+ if (frame.kind === "rejected") {
1427
+ const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
1428
+ this.rejectedHandler({
1429
+ clientID: this.clientID,
1430
+ mid: frame.mid,
1431
+ name: entry?.name ?? frame.name ?? "",
1432
+ args: entry !== undefined ? entry.args : frame.args,
1433
+ }, frame.reason ?? "mutation rejected");
1434
+ return;
1435
+ }
1436
+ // kind === "deopt": Q6's completion — how often a routed mutation came back refused.
1437
+ this.routingReasons.set("deopt", (this.routingReasons.get("deopt") ?? 0) + 1);
1438
+ const entry = this.pendingMutations.find((p) => p.domain === domain && p.mid === frame.mid);
1439
+ if (entry) {
1440
+ entry.domain = "daemon";
1441
+ // Deal the fresh daemon mid but DISCARD its seq — the entry keeps its own (state-machine
1442
+ // step 4 above; the harmless dealSeq bump is accepted). This is the ONE place a dealt seq
1443
+ // is dropped, so "within one domain seq order == mid order" weakens to "except deopt
1444
+ // re-enqueues" — see the {@link PendingMutation.seq} doc.
1445
+ const { mid } = this.dealMid("daemon");
1446
+ entry.mid = mid;
1447
+ void this.channelFor("daemon").pushMutation({ clientID: this.clientID, mid, name: entry.name, args: entry.args });
1448
+ return;
1449
+ }
1450
+ if (frame.name === undefined)
1451
+ return; // not self-contained — nothing to re-invoke
1452
+ try {
1453
+ this.invokeWith(frame.name, frame.args, "daemon");
1454
+ }
1455
+ catch (err) {
1456
+ this.rejectedHandler({ clientID: this.clientID, mid: frame.mid, name: frame.name, args: frame.args }, `deopt re-invocation failed: ${String(err?.message ?? err)}`);
1457
+ }
1458
+ }
1459
+ /** §7.5 rule 3 (H-v): re-send `domain`'s unconfirmed pending envelopes with their ORIGINAL
1460
+ * mids, in mid order, on the domain's own channel. Folds with `mid === null` are excluded —
1461
+ * nothing was ever sent for them (the flush deals their mid). Envelopes are reconstructed from
1462
+ * the pending entries exactly as `invoke` shipped them (`clientID`/`mid`/`name`/`args` —
1463
+ * entries carry everything the wire needs). Idempotent under the domain's ledger: an APPLIED
1464
+ * mid dedups silently and its lmid coverage retires the entry; a NON-APPLIED mid is re-answered
1465
+ * from the shell's recorded-outcome map into {@link handleMutationOutcome}. Confirmed entries
1466
+ * are already gone from `pendingMutations`, so no filter against the watermark is needed. */
1467
+ resendPending(domain) {
1468
+ const unconfirmed = this.pendingMutations
1469
+ .filter((p) => p.domain === domain && p.mid !== null)
1470
+ .sort((a, b) => a.mid - b.mid);
1471
+ if (unconfirmed.length === 0)
1472
+ return;
1473
+ const channel = this.channelFor(domain);
1474
+ for (const p of unconfirmed) {
1475
+ void channel.pushMutation({ clientID: this.clientID, mid: p.mid, name: p.name, args: p.args });
1476
+ }
1477
+ }
507
1478
  /** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
508
1479
  * `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
509
1480
  flushFolds() {
@@ -602,7 +1573,19 @@ export class OptimisticBackend {
602
1573
  const pending = this.pendingMutations.map((p) => {
603
1574
  const folded = foldByEntry.get(p);
604
1575
  const key = p.mid != null ? `m:${p.mid}` : folded ? `f:${folded[0]}` : `?:${p.name}`;
605
- const out = { key, mid: p.mid, name: p.name, args: p.args, tables: [...p.touched] };
1576
+ const writes = [];
1577
+ for (const byPk of p.writes.values())
1578
+ for (const rec of byPk.values())
1579
+ writes.push(rec);
1580
+ const out = {
1581
+ key,
1582
+ mid: p.mid,
1583
+ name: p.name,
1584
+ args: p.args,
1585
+ tables: [...p.touched],
1586
+ writes,
1587
+ reads: { reads: [...p.reads.reads], queries: [...p.reads.queries] },
1588
+ };
606
1589
  if (folded) {
607
1590
  const f = folded[1];
608
1591
  out.fold = {
@@ -617,13 +1600,69 @@ export class OptimisticBackend {
617
1600
  });
618
1601
  return {
619
1602
  pending,
620
- confirmedLmid: this.confirmedLmid,
621
- nextMid: this.nextMid,
622
- appliedCv: this.appliedCv,
623
- bufferedFrames: this.buffer.length,
1603
+ // Back-compat scalars for the devtools `OptimisticInspect` mirror (unchanged shape): the DAEMON
1604
+ // domain's ledger — the only one in single-domain. Per-domain state is `__inspectDomains()`.
1605
+ confirmedLmid: this.watermark.get("daemon") ?? 0,
1606
+ nextMid: this.nextMid.get("daemon") ?? 1,
1607
+ appliedCv: this.daemonGate.appliedCv,
1608
+ bufferedFrames: this.daemonGate.buffer.length,
624
1609
  pendingTables: [...this.pendingTables()],
625
1610
  };
626
1611
  }
1612
+ /** Test-only per-domain ledger snapshot (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1/§8.5).
1613
+ * Kept separate from {@link __inspect} so the devtools `OptimisticInspect` mirror stays byte-for-
1614
+ * byte identical (daemon-scalar-only). Exposes the per-domain `nextMid`/`watermark` maps plus each
1615
+ * pending entry's confirming domain — the axes the §8.5 ledger-isolation assertion checks. */
1616
+ __inspectDomains() {
1617
+ return {
1618
+ nextMid: Object.fromEntries(this.nextMid),
1619
+ watermark: Object.fromEntries(this.watermark),
1620
+ gates: Object.fromEntries([...this.gates].map(([k, g]) => [
1621
+ k,
1622
+ {
1623
+ appliedCv: g.appliedCv,
1624
+ bufferedFrames: g.buffer.length,
1625
+ ...(g.upstreamCv !== undefined ? { upstreamCv: g.upstreamCv } : {}),
1626
+ ...(g.upstreamBoot !== undefined ? { upstreamBoot: g.upstreamBoot } : {}),
1627
+ },
1628
+ ])),
1629
+ routing: {
1630
+ derived: Object.fromEntries(this.routingDerived),
1631
+ reasons: Object.fromEntries(this.routingReasons),
1632
+ },
1633
+ lifecycle: {
1634
+ roomWatermarks: Object.fromEntries(this.roomWatermarks),
1635
+ scopeSessions: Object.fromEntries([...this.scopeSessions].map(([scope, sessions]) => [scope, Object.fromEntries(sessions)])),
1636
+ ghosts: Object.fromEntries([...this.ghosts].map(([k, g]) => [k, { doc: g.doc, finalFlushSeq: g.finalFlushSeq, tables: [...g.tables] }])),
1637
+ },
1638
+ pins: Object.fromEntries([...this.pins].map(([k, p]) => [
1639
+ k,
1640
+ {
1641
+ table: p.table,
1642
+ sourceKey: p.sourceKey,
1643
+ domain: p.domain,
1644
+ mid: p.mid,
1645
+ ...(p.daemonBoot !== undefined ? { daemonBoot: p.daemonBoot } : {}),
1646
+ ...(p.daemonCv !== undefined ? { daemonCv: p.daemonCv } : {}),
1647
+ tripwired: p.tripwired === true,
1648
+ },
1649
+ ])),
1650
+ daemonCarriedLmid: Object.fromEntries(this.daemonCarriedLmid),
1651
+ pending: this.pendingMutations.map((p) => ({
1652
+ mid: p.mid,
1653
+ // The client-global deal sequence — the REPLAY order (mids are per-domain, incomparable
1654
+ // across domains; see PendingMutation.seq). The harness asserts send order with this.
1655
+ seq: p.seq,
1656
+ name: p.name,
1657
+ domain: p.domain,
1658
+ // The write-source axes (§5.3): which physical source(s) this entry's writes routed onto —
1659
+ // the filter basis, and (per pk) the hold-back's cross-slice test. The harness asserts the
1660
+ // filter/routing against its independent model with these.
1661
+ touchedSources: [...p.touchedSources],
1662
+ writeSources: Object.fromEntries(p.writeSources),
1663
+ })),
1664
+ };
1665
+ }
627
1666
  /** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
628
1667
  * from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
629
1668
  * (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
@@ -636,18 +1675,39 @@ export class OptimisticBackend {
636
1675
  }
637
1676
  }
638
1677
  }
639
- // --- the downstream stream (§8.5: buffer, then release coherently) ---------------
640
- onNormalized(qid, ev) {
641
- if (qid !== LMID_QID)
1678
+ // --- the downstream stream (§8.5: buffer, then release coherently — PER GATE, §5.1) ------
1679
+ onFrame(gate, qid, ev) {
1680
+ // Ownership is fixed at RETAIN time (G-iii registration-time routing: {@link RemoteSub.channel},
1681
+ // the one source of truth) — a qid lives on the ONE channel its sub registered on. So a frame
1682
+ // arriving on any OTHER gate means the server routed a qid to the wrong channel: a wiring bug —
1683
+ // fail loudly rather than silently splitting one query's frames across two cv timelines. (This
1684
+ // used to be a lazy first-arrival CLAIM; it is now a pure assertion.) A qid with NO sub (a
1685
+ // harness-delivered raw feed) has no owner and buffers on the arriving gate; the per-channel
1686
+ // reserved LMID_QID is exempt — each gate owns its own.
1687
+ if (qid !== LMID_QID) {
1688
+ const owner = this.channelOf(qid);
1689
+ if (owner !== undefined && owner !== gate.key) {
1690
+ // I-iv retarget grace: a frame already in flight from the sub's PREVIOUS channel when
1691
+ // {@link retargetRemoteQuery} moved it (the unsubscribe races the server's last frames)
1692
+ // is stale, not a wiring bug — drop it. The grace window is exactly the deferred-GC
1693
+ // window: {@link flushRetargetGc} deletes the record, and the loud throw is restored.
1694
+ if (this.pendingRetargetGc.get(qid) === gate.key)
1695
+ return;
1696
+ throw new Error(`optimistic backend: qid ${qid} arrived on ${gate.key} but is owned by ${owner}`);
1697
+ }
1698
+ }
1699
+ // System-plane frames (I-iii) are bookkeeping, not view data: like the lmid stream they skip
1700
+ // the devtools server-delta tap (there is no local view to attribute them to).
1701
+ if (qid !== LMID_QID && !this.systemQids.has(qid))
642
1702
  this.emitServerDelta(qid, ev);
643
1703
  if (ev.type === "hello") {
644
1704
  // A hello is a (re)subscribe = a NEW epoch. The column map below is mutated eagerly, but
645
- // data frames are cv-buffered and drained later (onProgress). So any frame still buffered
646
- // for this qid is from a SUPERSEDED epoch and must NOT be scattered through this epoch's
647
- // (possibly changed) map — drop it. This epoch's snapshot, which always follows the hello,
648
- // re-hydrates the qid from scratch, so the dropped frames are redundant. Scoped to this
649
- // qid: other queries' frames (and the lmid system query's) keep their coherent release.
650
- this.buffer = this.buffer.filter((f) => f.qid !== qid);
1705
+ // data frames are cv-buffered and drained later (the gate's progress). So any frame still
1706
+ // buffered for this qid is from a SUPERSEDED epoch and must NOT be scattered through this
1707
+ // epoch's (possibly changed) map — drop it. This epoch's snapshot, which always follows the
1708
+ // hello, re-hydrates the qid from scratch, so the dropped frames are redundant. Scoped to
1709
+ // this qid: other queries' frames (and the lmid system query's) keep their coherent release.
1710
+ gate.buffer = gate.buffer.filter((f) => f.qid !== qid);
651
1711
  this.addServerDependencyTables(qid, ev.tables.map((t) => t.name));
652
1712
  // Learn this query's per-table column map (PROJECTION-SUPPORT-DESIGN.md §5.2): map each
653
1713
  // advertised column to its base ColId BY NAME. The hello may carry FEWER columns than the
@@ -667,18 +1727,18 @@ export class OptimisticBackend {
667
1727
  // epoch left (a server that expanded then contracted back), so the now-exact rows don't
668
1728
  // scatter through a `-1`-bearing layout (silent cell corruption).
669
1729
  if (cols.length !== full || cols.some((c, i) => c !== i))
670
- this.sync.registerProjection(qid, t.name, cols);
1730
+ gate.sync.registerProjection(qid, t.name, cols);
671
1731
  else
672
- this.sync.unregisterProjection(qid, t.name);
1732
+ gate.sync.unregisterProjection(qid, t.name);
673
1733
  }
674
1734
  return; // envelope validation is the source's job
675
1735
  }
676
1736
  const cv = ev.cv ?? 0;
677
- if (cv <= this.appliedCv && ev.type === "batch")
678
- return; // stale redelivery
679
- this.buffer.push({ cv, qid, kind: ev.type, ops: ev.ops, seq: this.nextSeq++ });
680
- if (this.buffer.length > this.bufferCap)
681
- this.overflow();
1737
+ if (cv <= gate.appliedCv && ev.type === "batch")
1738
+ return; // stale redelivery ON THIS TIMELINE
1739
+ gate.buffer.push({ cv, qid, kind: ev.type, ops: ev.ops, seq: gate.nextSeq++ });
1740
+ if (gate.buffer.length > this.bufferCap)
1741
+ this.overflow(gate);
682
1742
  }
683
1743
  emitServerDelta(sourceQid, ev) {
684
1744
  if (!this.devObservers.size)
@@ -696,70 +1756,570 @@ export class OptimisticBackend {
696
1756
  const localQids = [...sub.localQids.keys()];
697
1757
  return localQids.length ? localQids : [sourceQid];
698
1758
  }
699
- onProgress(frame) {
700
- // (1) Take every buffered frame at cv cvMin, in (cv, arrival) order, and fold it:
701
- // lmid system-query frames advance `confirmedLmid`; data frames fold through the
702
- // cross-query refcount into ONE net base delta the §1.3 `D`. Confirmation and
703
- // data of the same commit share a cv, so they release together by construction.
704
- const ready = this.buffer
1759
+ /** One gate's release (§5.1 release gate): compute the coherent delta from THIS gate's cv-buffer,
1760
+ * then apply it against the gate's source/domain. Split into {@link computeRelease} (buffer
1761
+ * delta, lmid watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
1762
+ * N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
1763
+ onGateProgress(gate, frame) {
1764
+ // §301 direction B: record the frame's upstream-absorption advert BEFORE the release applies,
1765
+ // so the drop pass at applyRelease's tail evaluates exactly this release's advert — the room
1766
+ // emits data-then-progress on one socket, so by the time the advert says "absorbed through
1767
+ // cv U" the re-published echo data is in this release's fold (never ahead of it). Verbatim,
1768
+ // not max-folded: on a daemon restart the advertised cv space legitimately resets and the
1769
+ // §2.4 boot rule (not monotonicity) carries the ordering.
1770
+ if (frame.upstreamCv !== undefined) {
1771
+ gate.upstreamCv = frame.upstreamCv;
1772
+ gate.upstreamBoot = frame.upstreamBoot;
1773
+ }
1774
+ const { deltas, newlyHydrated, touchedScopes } = this.computeRelease(gate, frame);
1775
+ this.applyRelease(gate.key, deltas, undefined, newlyHydrated);
1776
+ // I-iv phase 2: a retargeted sub whose first ROOM snapshot released just now gets its old
1777
+ // channel's rows GC'd — AFTER the release fully applied, so the winner flip is value-equal
1778
+ // against the freshly-folded room rows (never a remove-before-the-refill).
1779
+ this.flushRetargetGc(gate);
1780
+ // I-iv doorbell events LAST — everything this release carried (data, confirms, the occupancy
1781
+ // fold itself, the retarget cutover) is already applied when the consumer's reaction (an
1782
+ // async re-lease) is kicked off. One event per touched scope, count evaluated at the fold
1783
+ // clock's now (deterministic under an injected clock).
1784
+ if (touchedScopes !== null) {
1785
+ for (const scope of touchedScopes) {
1786
+ this.scopeSessionsHandler({ scope, others: this.otherScopeSessions(scope) });
1787
+ }
1788
+ }
1789
+ }
1790
+ /** Compute one coherent release from ONE gate's cv-buffer (§5.1) — gate-scoped: its buffer, its
1791
+ * cvMin timeline. Take every buffered frame at `cv ≤ cvMin`, in (cv, arrival) order, and fold
1792
+ * it: the lmid system-query frame advances `watermark[gate.key]` (via {@link foldLmidOps} — the
1793
+ * daemon stream folds "daemon", a room stream folds its own domain); data frames fold through
1794
+ * this SOURCE's cross-query refcount into ONE net base delta — the §1.3 `D`. Returns that delta
1795
+ * plus the set of local views this release JUST hydrated (so their reconcile batch phases as a
1796
+ * `snapshot`). Mutates the gate's buffer/`appliedCv`, hydration, and the gate's domain
1797
+ * watermark; the pending set and the reconcile are {@link applyRelease}'s job. */
1798
+ computeRelease(gate, frame) {
1799
+ // Snapshot which local views are already hydrated BEFORE this release folds: any that cross into
1800
+ // hydrated below get their first result set as this cycle's batch, which must phase as a snapshot.
1801
+ const wasHydrated = new Set(this.hydrated);
1802
+ const ready = gate.buffer
705
1803
  .filter((f) => f.cv <= frame.cvMin)
706
1804
  .sort((a, b) => a.cv - b.cv || a.seq - b.seq);
707
- this.buffer = this.buffer.filter((f) => f.cv > frame.cvMin);
1805
+ gate.buffer = gate.buffer.filter((f) => f.cv > frame.cvMin);
1806
+ // The §4 lifecycle SYSTEM frames fold FIRST, in a FIXED structural category order (Slice
1807
+ // I-iii; see {@link foldSystemFrames} for why the order is load-bearing), then the ordinary
1808
+ // lmid + data frames fold exactly as before. With no system retain the partition is empty and
1809
+ // this release is byte-identical to pre-I-iii. The returned scope set feeds the I-iv doorbell
1810
+ // events `onGateProgress` fires once the WHOLE release has applied.
1811
+ const touchedScopes = this.foldSystemFrames(ready.filter((f) => this.systemQids.has(f.qid)));
708
1812
  const muts = [];
709
1813
  for (const f of ready) {
1814
+ if (this.systemQids.has(f.qid)) {
1815
+ // Folded above; a system stream has no store view and MUST NOT enter the sync layer (its
1816
+ // tables are not in the schema) — but its first snapshot still marks the sub hydrated so
1817
+ // the overflow/introspection bookkeeping stays uniform.
1818
+ if (f.kind === "snapshot")
1819
+ this.markSubHydrated(f.qid);
1820
+ continue;
1821
+ }
710
1822
  if (f.qid === LMID_QID) {
711
- this.foldLmidOps(f.ops);
1823
+ // Confirmation and data of the same commit share a cv, so they release together — each
1824
+ // channel's lmid stream folds into ITS OWN domain's watermark (§7.1).
1825
+ this.foldLmidOps(f.ops, gate.key);
712
1826
  continue;
713
1827
  }
714
- muts.push(...(f.kind === "snapshot" ? this.sync.rehydrate(f.qid, f.ops) : this.sync.applyBatch(f.qid, f.ops)));
1828
+ muts.push(...(f.kind === "snapshot" ? gate.sync.rehydrate(f.qid, f.ops) : gate.sync.applyBatch(f.qid, f.ops)));
715
1829
  // A query's first released snapshot is its hydration point — even an empty one (0 rows is an
716
1830
  // authoritative answer): lift every local view this sub feeds out of `unknown` (loading).
717
1831
  if (f.kind === "snapshot")
718
1832
  this.markSubHydrated(f.qid);
719
1833
  }
720
- this.appliedCv = Math.max(this.appliedCv, frame.cvMin);
721
- // (2) Drop confirmed pending (§1.3 step 5's bookkeeping half): mid ≤ the lmid this
722
- // release itself delivered. A failed mutation drops the same way — the release just
723
- // carries no effects for it, so the rewind in (3) snaps the prediction back. An UNFLUSHED
724
- // folded entry (`mid == null`) is never confirmable — it has not crossed the wire — so it is
725
- // always retained until its own flush stamps a real mid (FOLDED-MUTATIONS-DESIGN §4.1).
1834
+ gate.appliedCv = Math.max(gate.appliedCv, frame.cvMin);
1835
+ let newlyHydrated = null;
1836
+ for (const qid of this.hydrated) {
1837
+ if (!wasHydrated.has(qid))
1838
+ (newlyHydrated ??= new Set()).add(qid);
1839
+ }
1840
+ return { deltas: muts, newlyHydrated, touchedScopes };
1841
+ }
1842
+ /** Apply one released delta against `sourceKey`'s domain (§7.2 per-domain confirm-drop + the §1.3
1843
+ * reconcile cycle). `watermarkUpdate`, when given, advances `watermark[sourceKey]` first — the
1844
+ * hook a per-source lmid confirm rides on (the daemon path folds its watermark in
1845
+ * {@link computeRelease} and passes `undefined`). Then: drop every pending entry its OWN domain's
1846
+ * watermark now covers (a room confirm can never retire a daemon entry, and vice-versa — the §7.1
1847
+ * ledger-collision fix), and run the reconcile cycle against `sourceKey` when the base delta or the
1848
+ * pending set changed. `newlyHydrated` stamps the initial-hydration batch as a catch-up. */
1849
+ applyRelease(sourceKey, deltas, watermarkUpdate, newlyHydrated = null) {
1850
+ if (watermarkUpdate !== undefined) {
1851
+ this.watermark.set(sourceKey, Math.max(this.watermark.get(sourceKey) ?? 0, watermarkUpdate));
1852
+ }
1853
+ // Echo hold-back (§7.3), BEFORE the confirm-drop: an entry `p` this release CONFIRMS
1854
+ // (`p.mid <= watermark[p.domain]`) but which staged a write onto a DIFFERENT source `S` (a
1855
+ // cross-slice write — e.g. a room-domain mutation whose write routed to the daemon slice) is
1856
+ // about to leave `pendingMutations`, so it will NOT be re-invoked on `S`'s next rewind. Park each
1857
+ // such write inertly on `S` so that rewind (which un-applies the not-re-invoked write) does not
1858
+ // flash-revert it. Two variants, one per write shape (G-i unified them engine-side):
1859
+ // - ADD/EDIT (`rec.row` present): pinned PRESENCE — drops when `S`'s confirmed baseline
1860
+ // value-matches (its echo lands — the E-i-tested drop).
1861
+ // - REMOVE (`rec.row === undefined`, G-iii): pinned ABSENCE — the §7.3 tombstone, parked with
1862
+ // the captured pre-image (`rec.oldRow`) so `S`'s rewind cannot resurrect the deleted row;
1863
+ // drops when `S`'s baseline lacks the pk (the delete echoed).
1864
+ // Inert in single-domain: every write's source equals its domain.
1865
+ for (const p of this.pendingMutations) {
1866
+ if (p.mid === null || p.mid > (this.watermark.get(p.domain) ?? 0))
1867
+ continue; // not confirmed now
1868
+ for (const [table, byPk] of p.writes) {
1869
+ // The Collapsed-park gate (G-iii): NEVER park on an un-promoted table. Inert-anyway today
1870
+ // (`rewind_collapsed` never consults `held_back`), so skipping is behaviorally identical —
1871
+ // but a stale Collapsed-era entry would become overlay-first-visible (a permanent pin) if
1872
+ // the table later promotes. Slice H's prove-or-slow-path routing makes the real scenario
1873
+ // (a room-confirmed mutation writing a Collapsed table) impossible; this covers until then.
1874
+ if (!this.promotedTables.has(table))
1875
+ continue;
1876
+ for (const [pkKey, rec] of byPk) {
1877
+ const s = p.writeSources.get(writeSourceKey(table, pkKey));
1878
+ if (s === undefined || s === p.domain)
1879
+ continue; // same-slice: the rewind replays it
1880
+ if (rec.row !== undefined)
1881
+ this.local.holdBack(table, s, rec.row);
1882
+ else if (rec.oldRow !== undefined)
1883
+ this.local.holdBackAbsent(table, s, rec.oldRow);
1884
+ else
1885
+ continue;
1886
+ // §301 §2.1: register the pin's delivery-fence inputs in the same breath as the park.
1887
+ this.registerPin(table, s, pkKey, rec.row ?? rec.oldRow, p);
1888
+ }
1889
+ }
1890
+ }
1891
+ // Drop confirmed pending (§1.3 step 5's bookkeeping half), PER DOMAIN: an entry is retired only
1892
+ // when ITS domain's watermark reaches its mid — so two concurrent streams never alias one counter
1893
+ // (§7.1). A failed mutation drops the same way (the release carries no effects, so the rewind snaps
1894
+ // the prediction back). An UNFLUSHED fold (`mid == null`) is never confirmable — retained until its
1895
+ // flush stamps a real mid (FOLDED-MUTATIONS-DESIGN §4.1), regardless of any domain's watermark.
1896
+ // H-v NOTE — retiring here treats coverage as SUCCESS, which for a room domain is sound only
1897
+ // because outcome resolution ALWAYS precedes the coverage that retires: on the room socket
1898
+ // the outcome frames outrun the lmid acks (same-socket ordering + the resync re-send), and on
1899
+ // the daemon-carried path (I-iii) `foldSystemFrames` routes the co-committed outcome ROWS
1900
+ // through handleMutationOutcome BEFORE the ledger fold advances the watermark this filter
1901
+ // reads — a deopted entry has already flipped off the domain by the time its burnt mid is
1902
+ // covered, either way (the named invariant above handleMutationOutcome).
726
1903
  const before = this.pendingMutations.length;
727
- this.pendingMutations = this.pendingMutations.filter((p) => p.mid === null || p.mid > this.confirmedLmid);
1904
+ this.pendingMutations = this.pendingMutations.filter((p) => p.mid === null || p.mid > (this.watermark.get(p.domain) ?? 0));
728
1905
  const pendingChanged = this.pendingMutations.length !== before;
729
- // (3) The reconcile cycle — only when something can have changed: a base delta to
730
- // fold in, or a pending set that shrank (its optimistic layer must rewind out).
731
- if (muts.length || pendingChanged) {
732
- this.runReconcileCycle(muts);
1906
+ // The reconcile cycle — only when something can have changed: a base delta to fold in, or a
1907
+ // pending set that shrank (its optimistic layer must rewind out). The batch it emits for any view
1908
+ // that JUST became hydrated is that view's initial result set, so mark those qids so the
1909
+ // local-event forwarder stamps their batch `catchUp` (→ Store phases it `snapshot`).
1910
+ if (deltas.length || pendingChanged) {
1911
+ const emitted = (this.catchUpEmitted = new Set());
1912
+ this.catchUpQids = newlyHydrated;
1913
+ try {
1914
+ this.runReconcileCycle(sourceKey, deltas);
1915
+ }
1916
+ finally {
1917
+ this.catchUpQids = null;
1918
+ this.catchUpEmitted = null;
1919
+ }
1920
+ // Drop the qids the reconcile actually delivered a batch for; the rest folded nothing.
1921
+ if (newlyHydrated)
1922
+ for (const qid of emitted)
1923
+ newlyHydrated.delete(qid);
733
1924
  }
734
- // (4) ResultType is the SERVER CHANNEL's state only now (§7): `unknown` while not hydrated,
735
- // else `complete` — a pending mutation no longer moves it. The pending axis moves separately.
1925
+ // ResultType is the SERVER CHANNEL's state only now (§7): `unknown` while not hydrated, else
1926
+ // `complete` — a pending mutation no longer moves it. The pending axis moves separately.
736
1927
  for (const qid of this.queryTables.keys()) {
737
1928
  this.setResultType(qid, this.hydrated.has(qid) ? "complete" : "unknown");
738
1929
  }
1930
+ // A newly-hydrated query whose reconcile emitted NO batch (0 rows, its whole result already present
1931
+ // via a sibling → 0 net muts, or the reconcile was skipped) still needs a hydration signal, or its
1932
+ // SSR seed never retires and the view freezes. Send an explicit empty catch-up (now that it reads
1933
+ // `complete`, the Store retires the seed and reveals whatever is already in its tree).
1934
+ if (newlyHydrated) {
1935
+ for (const qid of newlyHydrated)
1936
+ this.handler(qid, { type: "batch", events: [], catchUp: true });
1937
+ }
739
1938
  this.refreshPending();
1939
+ // The §301 echo-fence drop pass — strictly AFTER the reconcile above folded this release's
1940
+ // data, so a fence that cleared in this release drops its pin only once the co-committed
1941
+ // echo data is actually in the baseline (dropping earlier would itself reopen the flash
1942
+ // window §7.3 closes). Structural no-op with no pins (every single-domain client).
1943
+ this.dropEchoFencePins();
1944
+ // The I-v ghost-drop watcher (§4.2), LAST: this release's watermark rows have folded
1945
+ // (computeRelease) and its confirm-drop has retired what it covers — exactly the two inputs
1946
+ // the drop condition reads. Structural no-op with no ghost.
1947
+ this.evaluateGhosts();
1948
+ }
1949
+ /** Test-only per-source release seam (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.2/§8.5): drive
1950
+ * {@link applyRelease} for `sourceKey` directly — an explicit `watermarkUpdate` (a simulated lmid
1951
+ * confirm for that domain) and `deltas` (a coherent base delta), with no real gate. Lets a harness
1952
+ * exercise a room-domain confirm before the real second lmid stream / per-source gate is wired
1953
+ * (E-iii-b/c). The `__`-prefix marks it a test hook, alongside {@link __inspect}. */
1954
+ __testRelease(sourceKey, deltas, watermarkUpdate) {
1955
+ this.applyRelease(sourceKey, deltas, watermarkUpdate);
1956
+ }
1957
+ // --- the §301 echo fences (301-ECHO-FENCE-DESIGN.md) -----------------------------------
1958
+ /** Register (or overwrite — a later cross-slice confirm re-pinning the same slice-pk matches
1959
+ * the engine's `held_back.insert`) one {@link EchoFencePin}, in the same breath as the
1960
+ * engine park (§2.1). `p` is the confirmed entry whose write staged onto `sourceKey`; its
1961
+ * `mid` is non-null by the caller's confirm filter. */
1962
+ registerPin(table, sourceKey, pkKey, probeRow, p) {
1963
+ const pin = {
1964
+ table,
1965
+ sourceKey,
1966
+ probeRow,
1967
+ domain: p.domain,
1968
+ mid: p.mid,
1969
+ };
1970
+ if (sourceKey !== "daemon" && p.domain === "daemon") {
1971
+ // Direction B stamps (§1.2): the parking daemon release's coherence position — the confirm
1972
+ // folded at-or-before it, so a room advertising absorption ≥ this cv has absorbed the
1973
+ // commit that carried the write — plus the client-observed boot it belongs to (§2.4).
1974
+ pin.daemonCv = this.daemonGate.appliedCv;
1975
+ if (this.daemonBootId !== undefined)
1976
+ pin.daemonBoot = this.daemonBootId;
1977
+ }
1978
+ // The tripwire reference (§2.5): the slice's confirmed-baseline row at park.
1979
+ pin.baselineAtPark = this.local.heldBackState(table, sourceKey, probeRow)?.baseline;
1980
+ this.pins.set(`${table}\0${sourceKey}\0${pkKey}`, pin);
1981
+ }
1982
+ /** The §2.3 drop pass, run at the tail of every applied release: prune registry entries whose
1983
+ * engine pin is already gone (the rewind's state-match fallback or a whole-source removal beat
1984
+ * the fence — benign), drop every pin whose fence cleared (one engine drop each, delivered on
1985
+ * the ordinary event stream, bracketed as ONE notification commit), and tripwire the rest. */
1986
+ dropEchoFencePins() {
1987
+ if (this.pins.size === 0)
1988
+ return; // every single-domain client: structural no-op
1989
+ let cleared = null;
1990
+ for (const [key, pin] of this.pins) {
1991
+ const state = this.local.heldBackState(pin.table, pin.sourceKey, pin.probeRow);
1992
+ if (state === undefined) {
1993
+ this.pins.delete(key); // engine pin gone (state-match / source removal): lazy prune
1994
+ continue;
1995
+ }
1996
+ if (this.pinFenceCleared(pin))
1997
+ (cleared ??= []).push([key, pin]);
1998
+ else
1999
+ this.maybeTripwirePin(key, pin, state.baseline);
2000
+ }
2001
+ if (!cleared)
2002
+ return;
2003
+ this.inOneCommit(() => {
2004
+ for (const [key, pin] of cleared) {
2005
+ this.pins.delete(key);
2006
+ this.local.dropHeldBack(pin.table, pin.sourceKey, pin.probeRow);
2007
+ }
2008
+ });
2009
+ }
2010
+ /** Has `pin`'s delivery fence provably closed its confirm→echo window? (§2.3/§2.4.) */
2011
+ pinFenceCleared(pin) {
2012
+ if (pin.sourceKey === "daemon") {
2013
+ // Direction A: the daemon-carried ledger for the confirming room domain covers the mid —
2014
+ // the I-ii co-commit means this release (or an earlier one) folded the flush data that
2015
+ // carried it into the daemon baseline (§1.1).
2016
+ return (this.daemonCarriedLmid.get(pin.domain) ?? 0) >= pin.mid;
2017
+ }
2018
+ // Direction B: only a daemon-confirmed write has the daemon-cv stamp; a room-staged pin
2019
+ // confirmed by ANOTHER room (outside today's two-tier topology) has no fence.
2020
+ if (pin.daemonCv === undefined)
2021
+ return false;
2022
+ const gate = this.gates.get(pin.sourceKey);
2023
+ if (!gate || gate.upstreamCv === undefined)
2024
+ return false; // no advert (old shell): §2.5 fallback
2025
+ if (gate.upstreamBoot === pin.daemonBoot)
2026
+ return gate.upstreamCv >= pin.daemonCv;
2027
+ // Boot mismatch (§2.4): a boot the client has OBSERVED as later proves absorption (the
2028
+ // room's post-restart re-snapshot came from daemon state that durably includes the
2029
+ // confirmed write); an unknown/older/unstamped boot holds — conservative, its next
2030
+ // re-snapshot advances it.
2031
+ if (pin.daemonBoot === undefined || gate.upstreamBoot === undefined)
2032
+ return false;
2033
+ const pinOrd = this.daemonBootOrdinals.get(pin.daemonBoot);
2034
+ const advOrd = this.daemonBootOrdinals.get(gate.upstreamBoot);
2035
+ return pinOrd !== undefined && advOrd !== undefined && advOrd > pinOrd;
740
2036
  }
741
- /** Fold the lmid system query's released ops (lmid-as-data): the one row's
742
- * `last_mutation_id` cell is this client's confirmed high-water mid. */
743
- foldLmidOps(ops) {
2037
+ /** The §2.5 stuck-pin tripwire, in the scopesHash spirit: log ONCE per pin when its slice's
2038
+ * baseline row has CHANGED VALUE since park while the pin still holds — the suspicious state
2039
+ * that precedes every forever-pin (a fence-less pairing, a fence bug). Never drops anything. */
2040
+ maybeTripwirePin(key, pin, baseline) {
2041
+ if (pin.tripwired)
2042
+ return;
2043
+ const same = pin.baselineAtPark === undefined || baseline === undefined
2044
+ ? pin.baselineAtPark === baseline
2045
+ : pin.baselineAtPark.length === baseline.length &&
2046
+ pin.baselineAtPark.every((c, i) => identicalCell(c, baseline[i]));
2047
+ if (same)
2048
+ return;
2049
+ pin.tripwired = true;
2050
+ const [table, sourceKey, pkKey] = key.split("\0");
2051
+ console.warn(`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).`);
2052
+ }
2053
+ /** Record one observed daemon boot id (§2.4): first observation of an id assigns the next
2054
+ * ordinal (the client's own total order over opaque boot ids); every call refreshes the
2055
+ * current-boot stamp for direction-B parks. */
2056
+ observeDaemonBoot(bootId) {
2057
+ if (!this.daemonBootOrdinals.has(bootId)) {
2058
+ this.daemonBootOrdinals.set(bootId, this.daemonBootOrdinals.size);
2059
+ }
2060
+ this.daemonBootId = bootId;
2061
+ }
2062
+ /** Promote `table` to a MERGED multi-source engine with room `sourceKey`'s per-row writable
2063
+ * scope (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §5.2) — THE one promotion seam. The engine
2064
+ * attach and the {@link promotedTables} record move in the same breath: the §7.3 hold-back
2065
+ * trigger in {@link applyRelease} parks ONLY on promoted tables, so a promotion that bypassed
2066
+ * the record would silently disable the echo hold-back for that table (and a record without the
2067
+ * engine attach would park onto a slice that doesn't exist). Recorded AFTER the engine accepts —
2068
+ * a rejected descriptor/table must not leave a phantom promotion. Slice G-v's client drives this
2069
+ * from the lease's `realtime.tables` (`RoomTableSpec` → {@link WritableDescriptor}); idempotence
2070
+ * per `(sourceKey, table)` is the CALLER's job (the engine refuses a duplicate room).
2071
+ *
2072
+ * `spec` (H-iii) is the per-table ROUTING spec riding the same lease table block — recorded
2073
+ * into {@link roomRouting} (THE routing table the §3 router reads) in the same breath, with
2074
+ * `writable` derived from the descriptor's kind (`none` = a context table the room may not
2075
+ * write). Omitted (the E-iii-b harness alias below) ⇒ an empty spec: no join keys to guard, no
2076
+ * `footprintWhere` (that room's reads then fail closed to the daemon unless self/room-served). */
2077
+ promoteRoomTable(table, sourceKey, writable, spec) {
2078
+ this.local.addRoomSource(table, sourceKey, writable);
2079
+ this.promotedTables.add(table);
2080
+ let byTable = this.roomRouting.get(sourceKey);
2081
+ if (!byTable)
2082
+ this.roomRouting.set(sourceKey, (byTable = new Map()));
2083
+ byTable.set(table, {
2084
+ writable: writable.kind !== "none",
2085
+ joinKeyCols: [...(spec?.joinKeyCols ?? [])],
2086
+ ...(spec?.where !== undefined ? { where: spec.where } : {}),
2087
+ ...(spec?.footprintWhere !== undefined ? { footprintWhere: spec.footprintWhere } : {}),
2088
+ });
2089
+ }
2090
+ /** The recorded routing specs for `sourceKey`'s promoted tables (table → its
2091
+ * {@link RoomTableRouting}) — read-only: the client's `__realtimeInspect`/idempotence
2092
+ * bookkeeping reads THIS record instead of keeping its own shadow copy (one source of truth).
2093
+ * Empty map when the room has promoted nothing. */
2094
+ roomTablesFor(sourceKey) {
2095
+ return this.roomRouting.get(sourceKey) ?? EMPTY_ROUTING;
2096
+ }
2097
+ /** Test-named alias of {@link promoteRoomTable} (E-iii-b scaffolding — the multi-domain oracle
2098
+ * and per-source-gate suites drive it). Pure delegation: ONE body, one `promotedTables` record. */
2099
+ __addRoomSource(table, roomKey, writable, spec) {
2100
+ this.promoteRoomTable(table, roomKey, writable, spec);
2101
+ }
2102
+ /** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
2103
+ * `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
2104
+ * `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
2105
+ * daemon stream folds `"daemon"`; a room stream folds its own `"room:doc:X"`; the daemon-carried
2106
+ * §7.1 ledger rows fold through the same {@link foldConfirm} core (Slice I-iii). */
2107
+ foldLmidOps(ops, domain) {
744
2108
  for (const op of ops) {
745
2109
  const row = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
746
2110
  if (!row)
747
2111
  continue; // a remove (client GC) confirms nothing
748
- const lmid = Number(row[1]);
749
- if (!Number.isFinite(lmid))
750
- continue;
751
- if (lmid > this.nextMid - 1) {
752
- if (this.pendingMutations.length > 0) {
753
- // The server confirmed a mid we never issued while we have mutations in
754
- // flighttwo writers on one clientID or corrupted state. Unrecoverable.
755
- throw new Error(`optimistic backend: confirmed lmid ${lmid} is ahead of issued mids (${this.nextMid - 1})`);
2112
+ this.foldConfirm(domain, Number(row[1]));
2113
+ }
2114
+ }
2115
+ /** THE one confirm fold (§7.1/§7.2): advance `watermark[domain]` to `lmid` (monotone max) and,
2116
+ * on a fresh session ahead of our issued mids, adopt `nextMid[domain]`. Shared verbatim by the
2117
+ * per-channel lmid system query ({@link foldLmidOps}) and the daemon-carried room-ledger rows
2118
+ * ({@link foldSystemFrames}one core so the two paths cannot drift). */
2119
+ foldConfirm(domain, lmid) {
2120
+ if (!Number.isFinite(lmid))
2121
+ return;
2122
+ const highestIssued = (this.nextMid.get(domain) ?? 1) - 1;
2123
+ if (lmid > highestIssued) {
2124
+ // Only in-flight mutations of THIS domain can contradict its watermark (§7.1: the counters
2125
+ // are independent — a room-domain mutation pending while the daemon's historical lmid
2126
+ // snapshot arrives is a normal fresh-session interleaving, not a second writer). An
2127
+ // unflushed fold (`mid == null`) has issued nothing yet either way: it cannot explain a
2128
+ // confirmed-ahead lmid, and its eventual flush deals from the adopted counter below.
2129
+ if (this.pendingMutations.some((p) => p.domain === domain && p.mid !== null)) {
2130
+ // The server confirmed a mid we never issued while we have mutations in
2131
+ // flight on this domain — two writers on one clientID or corrupted state. Unrecoverable.
2132
+ throw new Error(`optimistic backend: confirmed lmid ${lmid} is ahead of issued mids (${highestIssued})`);
2133
+ }
2134
+ // A fresh session over a clientID with history: adopt the server's high-water
2135
+ // mark so our next mid continues the sequence instead of colliding below it.
2136
+ this.nextMid.set(domain, lmid + 1);
2137
+ }
2138
+ this.watermark.set(domain, Math.max(this.watermark.get(domain) ?? 0, lmid));
2139
+ }
2140
+ // --- the §4 lifecycle system-stream folds (Slice I-iii) --------------------------------
2141
+ /** Fold one release's SYSTEM frames in a FIXED category order — the order is STRUCTURAL (one
2142
+ * function, categories in sequence), because it is the client half of THE NAMED INVARIANT
2143
+ * (§3.3's shipped note; documented above {@link handleMutationOutcome}): **never retire a
2144
+ * room-domain entry off a daemon-carried lmid without outcome resolution.**
2145
+ *
2146
+ * 1. **outcome rows** (`_rindle_room_mutation_outcomes`) — each row for OUR clientID is
2147
+ * synthesized into a {@link MutationOutcomeFrame} and routed through
2148
+ * {@link handleMutationOutcome}, the SAME H-v state machine the room socket's frames use
2149
+ * (one verdict path: frames and rows cannot drift). A deopt flips its pending entry to
2150
+ * the daemon IN PLACE (keep-seq, deal-and-send-now); a rejection surfaces + stays for the
2151
+ * ordinary burnt-mid retire; a duplicate (frame already seen, or the row re-delivered) is
2152
+ * absorbed by the processed set — which doubles as the resolved-verdict memory across
2153
+ * releases (per-domain FIFO, {@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}, mirroring the
2154
+ * shell's recorded-outcome cap).
2155
+ * 2. **room-ledger rows** (`_rindle_room_client_mutations`) — the FIRST daemon-carried
2156
+ * room-lmid path: OUR row's `last_mutation_id` folds into `watermark[room:<doc>]` via
2157
+ * {@link foldConfirm}. Because step 1 ALREADY resolved every non-applied verdict this
2158
+ * release carries (and earlier releases' verdicts were resolved at their own release),
2159
+ * the confirm-drop that follows in {@link applyRelease} retires only entries whose
2160
+ * outcome is resolution-by-absence — which I-ii's co-commit atomicity defines as APPLIED
2161
+ * (a room flush co-commits the ledger row and every non-applied mid's outcome row in ONE
2162
+ * daemon transaction, so a covering lmid without a row IS the applied verdict).
2163
+ * Processing this category before step 1 is the violation, in two proven directions
2164
+ * (each run break→fail→revert against `test/system_streams.test.ts`): (a) the ledger's
2165
+ * fresh-session `nextMid` ADOPTION must not run before historical outcome rows are
2166
+ * judged — adopted-first, a previous session's retained deopt row passes the
2167
+ * "never-issued" guard and spuriously re-invokes a mutation that session already handled
2168
+ * (a double-apply); (b) the RETIRE must not precede resolution — it does not BECAUSE the
2169
+ * confirm-drop runs in {@link applyRelease}, strictly after this whole function. That
2170
+ * deferral is load-bearing: an "optimization" retiring inline with the watermark fold
2171
+ * retires a deopted entry as a silent success (the exact lost-write H-v exists to
2172
+ * prevent) and mis-attributes a rejected row's reason.
2173
+ * 3. **watermark rows** (`_rindle_room_watermark`) — the §4.2 fence value, max-folded per
2174
+ * doc ({@link roomWatermarks}); I-v's ghost-drop consumer, no reaction here.
2175
+ * 4. **scope-session rows** (`_rindle_scope_sessions`) — the §4.1 occupancy map
2176
+ * ({@link scopeSessions}); I-iv's doorbell consumer, no reaction here.
2177
+ *
2178
+ * Ordinary data ops fold AFTER all of these (the caller's main loop) — outcome/ledger state
2179
+ * must be in place before {@link applyRelease}'s confirm-drop + reconcile consume the release.
2180
+ * Every row is filtered against the retain's {@link SystemStreamSpec} scope/doc AND (for the
2181
+ * client-keyed tables) our own `clientID` — defense in depth: the server predicate may have
2182
+ * been minted doc-only (no `clientId` at lease time), so other clients' rows are expected and
2183
+ * must be ignored, and a row for a doc this retain was not minted for is never folded.
2184
+ *
2185
+ * Returns the scopes category 4 touched (snapshot or ops) — the I-iv doorbell events' input;
2186
+ * `null` when none (every non-lifecycle release). The events themselves fire from
2187
+ * `onGateProgress` AFTER the release applies, never from inside the fold. */
2188
+ foldSystemFrames(frames) {
2189
+ if (frames.length === 0)
2190
+ return null;
2191
+ const byTable = (table) => frames.flatMap((frame) => {
2192
+ const spec = this.systemQids.get(frame.qid);
2193
+ return spec !== undefined && spec.table === table ? [{ spec, frame }] : [];
2194
+ });
2195
+ // (1) outcome rows → the H-v machine, BEFORE any ledger fold (the named invariant).
2196
+ for (const { spec, frame } of byTable(ROOM_MUTATION_OUTCOMES_TABLE)) {
2197
+ for (const op of frame.ops) {
2198
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
2199
+ if (!cells)
2200
+ continue; // a remove is retention pruning (mid ≤ lmid − 512), never a verdict
2201
+ const row = decodeOutcomeRow(cells);
2202
+ if (!row || row.clientId !== this.clientID)
2203
+ continue;
2204
+ if (spec.doc !== undefined && row.doc !== spec.doc)
2205
+ continue;
2206
+ const frameShape = {
2207
+ mid: row.mid,
2208
+ kind: row.kind,
2209
+ ...(row.reason !== undefined ? { reason: row.reason } : {}),
2210
+ ...(row.name !== undefined ? { name: row.name } : {}),
2211
+ ...(row.args !== undefined ? { args: row.args } : {}),
2212
+ };
2213
+ // Release-time invocation is sound here where out-of-band was REQUIRED for the socket
2214
+ // frames (`attachGate`): the socket frame races a buffered lmid ack it must beat, so it
2215
+ // may not wait behind the gate — a ROW cannot race its own release (it and the covering
2216
+ // ledger row co-committed at one cv and fold in THIS function's fixed order). The
2217
+ // machine's steps need nothing from an open release: the flip/reject only move pending
2218
+ // bookkeeping + ship an envelope, and the not-found re-invoke arm runs a fresh prediction
2219
+ // — legal before `applyRelease` opens the reconcile cycle, identical to an app invoke
2220
+ // racing the release.
2221
+ this.handleMutationOutcome(roomDomainKey(row.doc), frameShape);
2222
+ }
2223
+ }
2224
+ // (2) room-ledger rows → the daemon-carried per-domain confirm (outcomes above resolved first).
2225
+ for (const { spec, frame } of byTable(ROOM_CLIENT_MUTATIONS_TABLE)) {
2226
+ for (const op of frame.ops) {
2227
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
2228
+ if (!cells)
2229
+ continue; // a ledger remove confirms nothing (mirrors foldLmidOps)
2230
+ const [doc, clientId, lmid] = cells;
2231
+ if (typeof doc !== "string" || clientId !== this.clientID)
2232
+ continue;
2233
+ if (spec.doc !== undefined && doc !== spec.doc)
2234
+ continue;
2235
+ const covered = Number(lmid);
2236
+ this.foldConfirm(roomDomainKey(doc), covered);
2237
+ // The §301 direction-A fence input: the DAEMON-CARRIED ledger fold ALONE (the room
2238
+ // socket's own lmid stream confirms long before the flush reaches the daemon, so it
2239
+ // folds the shared watermark above but never this map — 301 §1.1). Max-fold: ledger
2240
+ // rows re-deliver across re-hydrates.
2241
+ if (Number.isFinite(covered)) {
2242
+ const dom = roomDomainKey(doc);
2243
+ this.daemonCarriedLmid.set(dom, Math.max(this.daemonCarriedLmid.get(dom) ?? 0, covered));
2244
+ }
2245
+ }
2246
+ }
2247
+ // (3) watermark rows → the monotone §4.2 fence value per doc.
2248
+ for (const { spec, frame } of byTable(ROOM_WATERMARK_TABLE)) {
2249
+ for (const op of frame.ops) {
2250
+ const cells = op.op === "add" ? op.row : op.op === "edit" ? op.new : undefined;
2251
+ if (!cells)
2252
+ continue; // the fence is monotone — a remove never regresses it
2253
+ const [doc, flushSeq] = cells;
2254
+ const seq = Number(flushSeq);
2255
+ if (typeof doc !== "string" || !Number.isFinite(seq))
2256
+ continue;
2257
+ if (spec.doc !== undefined && doc !== spec.doc)
2258
+ continue;
2259
+ this.roomWatermarks.set(doc, Math.max(this.roomWatermarks.get(doc) ?? 0, seq));
2260
+ }
2261
+ }
2262
+ // (4) scope-session rows → the §4.1 occupancy map (a snapshot REPLACES the scope's map — an
2263
+ // authoritative re-hydrate must drop sessions that aged out while the stream was down; a
2264
+ // batch folds add/edit/remove incrementally). Touched scopes are collected for the I-iv
2265
+ // doorbell events (a snapshot touches its minted scope even with zero ops — an emptied-out
2266
+ // scope is a legitimate 1→0 observation for the transition tracker).
2267
+ let touchedScopes = null;
2268
+ const touch = (scope) => {
2269
+ (touchedScopes ??= new Set()).add(scope);
2270
+ };
2271
+ for (const { spec, frame } of byTable(SCOPE_SESSIONS_TABLE)) {
2272
+ if (frame.kind === "snapshot" && spec.scope !== undefined) {
2273
+ this.scopeSessions.set(spec.scope, new Map());
2274
+ touch(spec.scope);
2275
+ }
2276
+ for (const op of frame.ops) {
2277
+ // A remove's identity rides its (full) removed row; add/edit carry the post-image.
2278
+ const cells = op.op === "edit" ? op.new : op.row;
2279
+ const [scope, clientId, expiresAt] = cells;
2280
+ if (typeof scope !== "string" || typeof clientId !== "string")
2281
+ continue;
2282
+ if (spec.scope !== undefined && scope !== spec.scope)
2283
+ continue;
2284
+ let sessions = this.scopeSessions.get(scope);
2285
+ if (!sessions)
2286
+ this.scopeSessions.set(scope, (sessions = new Map()));
2287
+ if (op.op === "remove") {
2288
+ sessions.delete(clientId);
756
2289
  }
757
- // A fresh session over a clientID with history: adopt the server's high-water
758
- // mark so our next mid continues the sequence instead of colliding below it.
759
- this.nextMid = lmid + 1;
2290
+ else {
2291
+ const exp = Number(expiresAt);
2292
+ if (Number.isFinite(exp))
2293
+ sessions.set(clientId, exp);
2294
+ }
2295
+ touch(scope);
760
2296
  }
761
- this.confirmedLmid = Math.max(this.confirmedLmid, lmid);
762
2297
  }
2298
+ return touchedScopes;
2299
+ }
2300
+ /** The I-iv occupancy count — THE one rule (§4.1/D7): unexpired (`expires_at >` the fold
2301
+ * clock's now) sessions under `scope` from OTHER clientIDs. Shared by the doorbell events
2302
+ * ({@link onGateProgress}) and the client's registration-time check (a doorbell that folded
2303
+ * BEFORE a candidate registered must still be able to trigger it) so the two can never
2304
+ * disagree. Own-clientID rows never count — a solo client cannot ring its own bell — and
2305
+ * expiry is judged on the injectable {@link FoldClock} (deterministic in a virtual-clock
2306
+ * harness, the folded-oracle discipline). */
2307
+ otherScopeSessions(scope) {
2308
+ const sessions = this.scopeSessions.get(scope);
2309
+ if (!sessions)
2310
+ return 0;
2311
+ const now = this.clock.now();
2312
+ let n = 0;
2313
+ for (const [clientId, expiresAt] of sessions) {
2314
+ if (clientId !== this.clientID && expiresAt > now)
2315
+ n++;
2316
+ }
2317
+ return n;
2318
+ }
2319
+ /** Register the I-iv doorbell event sink — see {@link ScopeSessionsEvent}. One handler (a later
2320
+ * registration replaces it, the {@link onLocalWrite} convention); client.ts is the consumer. */
2321
+ onScopeSessions(handler) {
2322
+ this.scopeSessionsHandler = handler;
763
2323
  }
764
2324
  /** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
765
2325
  * delta into BOTH head AND the `sync` baseline (`serverBatchBegin`), re-invoke every
@@ -767,37 +2327,61 @@ export class OptimisticBackend {
767
2327
  * deliver the coalesced result (`serverBatchEnd`). This is the engine's only sync-moving
768
2328
  * boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
769
2329
  * and sync never diverge (the §1.2 invariant; CRIT#2). */
770
- runReconcileCycle(serverDeltas) {
771
- this.local.serverBatchBegin(serverDeltas.map(toServerOp));
772
- // The rewind cleared every optimistic write incl. prior `__agg` edits so head is now
773
- // the server baseline. Rebuild the optimistic agg delta from scratch off the re-invoked
774
- // (confirm-filtered) pending set, so a just-confirmed mutation's delta vanishes exactly as
775
- // its server count is absorbed (§5 watermark — no double count).
776
- this.overlay.reset();
777
- // Re-invoke in WIRE order: assigned mids ascending, then unflushed folds (`mid == null`) last
778
- // by creation order the deterministic slot of FOLDED-MUTATIONS-DESIGN §4.1. A read-dependent
779
- // mutator must replay against the same base the server computed from, which is mid order; with
780
- // deferred fold mids, mid order ≠ creation order, so we sort rather than trust array order. The
781
- // comparator is explicit (NOT `(mid ?? ∞) - (mid ?? ∞)`, which is `∞ - ∞ = NaN` for two unflushed
782
- // foldsa NaN comparator silently corrupts V8's sort): assigned-before-unflushed, mids
783
- // ascending, and STABLE for two unflushed folds so they keep creation order across cycles.
784
- const order = [...this.pendingMutations].sort((a, b) => {
785
- if (a.mid === null && b.mid === null)
2330
+ runReconcileCycle(sourceKey, serverDeltas) {
2331
+ // `sourceKey` names the authority these `deltas` confirm — `"daemon"` on the live daemon path (and
2332
+ // the GC path), a `room:doc:X` string on a per-source release (E-iii). It selects which physical
2333
+ // source the wasm engine's rewind folds the delta into (`Db::serverBatchBegin`). The E-iii-b/c
2334
+ // per-source rewind + intersecting-pending re-invoke filter ride the same seam later.
2335
+ this.local.serverBatchBegin(sourceKey, serverDeltas.map(toServerOp));
2336
+ // The aggregate overlay (§4) is DAEMON-tracked: its `__agg_*` tables are server-authoritative
2337
+ // synthetic tables that only the daemon feed drives. A room cycle must not wipe or rebuild it
2338
+ // (that would double-count / lose daemon agg state), so gate every overlay interaction — reset,
2339
+ // observe, and reconcileAggHead on the daemon cycle. Single-domain: always daemon unchanged.
2340
+ const daemonCycle = sourceKey === "daemon";
2341
+ if (daemonCycle) {
2342
+ // The rewind cleared every optimistic write incl. prior `__agg` edits so head is now the
2343
+ // server baseline. Rebuild the optimistic agg delta from scratch off the re-invoked
2344
+ // (confirm-filtered) pending set, so a just-confirmed mutation's delta vanishes exactly as its
2345
+ // server count is absorbed (§5 watermark no double count).
2346
+ this.overlay.reset();
2347
+ }
2348
+ // Sort ALL pending into SEND order (the client-global `seq` ascending, then unflushed folds
2349
+ // last by creation order — the deterministic §4.1 slot; the comparator is explicit, NOT
2350
+ // `(seq ?? ∞) - (seq ?? ∞)` which is `∞ - ∞ = NaN` and corrupts V8's sort), THEN FILTER to the
2351
+ // writers of THIS source (§5.3): a per-source rewind un-applied ONLY that source's staged
2352
+ // writes, so ONLY those must re-invoke — the others' predictions are intact on their un-rewound
2353
+ // trees. The key MUST be `seq`, never `mid`: mids are per-domain (§7.1) so mids from different
2354
+ // domains are incomparable — a mid-sort would replay a room mid 1 before a daemon mid 5 that
2355
+ // was sent FIRST, letting a read-dependent mutator re-predict from a base it never saw
2356
+ // (confirmation order is per-domain; replay order is client-global). FILTERING the
2357
+ // globally-ordered array preserves the relative send order among the subset (the 200 §4.1
2358
+ // invariant); NEVER re-sort the subset. Single-domain: every entry has
2359
+ // `touchedSources = {"daemon"}` and seq order == mid order (except H-v deopt re-enqueues,
2360
+ // which keep their ORIGINAL seq under a later daemon mid — deliberately, so this very sort
2361
+ // replays them at their original overlay position) ⇒ byte-identical to before.
2362
+ const order = [...this.pendingMutations]
2363
+ .sort((a, b) => {
2364
+ if (a.seq === null && b.seq === null)
786
2365
  return 0; // both unflushed → stable creation order
787
- if (a.mid === null)
788
- return 1; // an unflushed fold sorts after every assigned mid
789
- if (b.mid === null)
2366
+ if (a.seq === null)
2367
+ return 1; // an unflushed fold sorts after every dealt seq
2368
+ if (b.seq === null)
790
2369
  return -1;
791
- return a.mid - b.mid;
792
- });
2370
+ return a.seq - b.seq;
2371
+ })
2372
+ .filter((p) => p.touchedSources.has(sourceKey));
793
2373
  const dropped = new Set();
794
2374
  try {
795
2375
  for (const p of order) {
796
- const touched = new Set();
2376
+ // NO `readLog` here — recording is armed only on the initial `invoke` (§3.2 #2 note on
2377
+ // `PendingMutation.reads`); a re-invocation's write-set still needs fresh capture (below).
2378
+ const writes = new Map();
797
2379
  const ops = [];
2380
+ const stagedKeys = [];
2381
+ let sources = [];
798
2382
  try {
799
- this.local.writeWith((tx) => {
800
- this.runMutator(this.registry[p.name], trackingTx(tx, touched, this.specs, this.localTables, this.opCollector(ops)), p.args);
2383
+ sources = this.local.writeWith((tx) => {
2384
+ this.runMutator(this.registry[p.name], trackingTx(tx, writes, this.specs, this.localTables, this.opCollector(ops), false, undefined, stagedKeys), p.args);
801
2385
  });
802
2386
  }
803
2387
  catch {
@@ -810,52 +2394,71 @@ export class OptimisticBackend {
810
2394
  dropped.add(p);
811
2395
  continue;
812
2396
  }
813
- // The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta.
814
- for (const op of ops)
815
- this.overlay.observe(op);
2397
+ // The re-invocation stuck — fold its child ops into the rebuilt optimistic agg delta (daemon
2398
+ // cycle only a room cycle leaves the daemon-tracked overlay untouched).
2399
+ if (daemonCycle)
2400
+ for (const op of ops)
2401
+ this.overlay.observe(op);
816
2402
  // The pending footprint is the UNION across invocations: a re-run that no-ops (touched =
817
2403
  // {}) must NOT shrink it, else a still-pending mutation reports not-pending and its
818
- // pending-axis clear fires early (§7.2).
819
- for (const t of touched)
2404
+ // pending-axis clear fires early (§7.2). `writes`/the source axes mirror this: merge, never
2405
+ // replace. (A re-invocation may route differently, e.g. onto a source the room feed now
2406
+ // holds — union so the filter still catches it on either source's next cycle.)
2407
+ for (const t of writes.keys())
820
2408
  p.touched.add(t);
2409
+ mergeWriteSet(p.writes, writes);
2410
+ for (const s of sources)
2411
+ p.touchedSources.add(s);
2412
+ mergeWriteSources(p.writeSources, stagedKeys, sources);
821
2413
  }
822
2414
  // Preserve creation order in the live array (the unflushed-fold sort tiebreak depends on it).
823
2415
  if (dropped.size)
824
2416
  this.pendingMutations = this.pendingMutations.filter((p) => !dropped.has(p));
825
2417
  // Re-apply the optimistic agg delta onto the (rewound) `__agg` head rows — INSIDE the open
826
2418
  // cycle, so the writes buffer and coalesce into the one per-query delivery `serverBatchEnd`
827
- // makes (and never escape as a separate batch).
828
- this.reconcileAggHead();
2419
+ // makes (and never escape as a separate batch). Daemon cycle only (see above).
2420
+ if (daemonCycle)
2421
+ this.reconcileAggHead();
829
2422
  }
830
2423
  finally {
831
2424
  this.local.serverBatchEnd(); // ALWAYS close the cycle — ONE delivery per affected query.
832
2425
  }
833
2426
  }
834
- /** The daemon restarted (a new boot id): it lost all materialization + `cv` state and its `cv`
835
- * sequence reset, so previously-released `cv`s no longer bound the new stream. The source has
836
- * already re-subscribed every query (reconnect → resync); drop the buffer and the `cv`
837
- * watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
838
- * (`onNormalized`/`onProgress` gate on `appliedCv`). Pending optimistic mutations stay put
839
- * they re-apply on the next reconcile, and the lmid system query's fresh snapshot restores the
840
- * confirmation watermark. */
841
- resetForRestart() {
842
- this.buffer = [];
843
- this.appliedCv = 0;
844
- }
845
- /** The §8.5 escape: the buffer outgrew its cap (a pinned `cvMin` under churn). Drop
846
- * everything buffered and re-register every query on the source the fresh
847
- * snapshots arrive as ordinary frames and the next release re-hydrates via the
848
- * footprint diff (the §5.3 path); still-pending optimism re-applies in that cycle. */
849
- overflow() {
850
- this.buffer = [];
2427
+ /** ONE channel's authority restarted (a new boot id): it lost all materialization + `cv` state
2428
+ * and its `cv` sequence reset, so previously-released `cv`s no longer bound the new stream. The
2429
+ * source has already re-subscribed every query (reconnect → resync); drop THIS gate's buffer
2430
+ * and `cv` watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
2431
+ * (`onFrame`/`computeRelease` gate on `appliedCv`). The OTHER gates are untouched an
2432
+ * authority restart is per-channel (§5.1). Pending optimistic mutations stay put they
2433
+ * re-apply on the next reconcile, and the channel's lmid system query's fresh snapshot restores
2434
+ * its domain's confirmation watermark. */
2435
+ resetGate(gate) {
2436
+ gate.buffer = [];
2437
+ gate.appliedCv = 0;
2438
+ // The §301 upstream-absorption advert dies with the incarnation that made it the fresh
2439
+ // one re-advertises (direction-B pins conservatively hold until it does).
2440
+ delete gate.upstreamBoot;
2441
+ delete gate.upstreamCv;
2442
+ }
2443
+ /** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
2444
+ * channel). Drop everything it buffered and re-register every query on that source — the fresh
2445
+ * snapshots arrive as ordinary frames and the next release re-hydrates via the footprint diff
2446
+ * (the §5.3 path); still-pending optimism re-applies in that cycle. The other gates' buffers
2447
+ * and subscriptions are untouched. */
2448
+ overflow(gate) {
2449
+ gate.buffer = [];
851
2450
  for (const sub of this.remoteSubs.values()) {
852
- this.source.unregisterQuery(sub.sourceQid);
853
- this.source.registerQuery(sub.sourceQid, sub.remote);
2451
+ // Re-register only the subs THIS channel owns ({@link RemoteSub.channel} — the one source
2452
+ // of truth, G-iii): resubscribing another channel's sub here would fork its stream.
2453
+ if (sub.channel !== gate.key)
2454
+ continue;
2455
+ gate.source.unregisterQuery(sub.sourceQid);
2456
+ gate.source.registerQuery(sub.sourceQid, sub.remote);
854
2457
  }
855
2458
  // The lmid system query's buffered frames were dropped too — re-subscribe it so a
856
2459
  // fresh snapshot restores the confirmation watermark.
857
- this.source.unregisterQuery(LMID_QID);
858
- this.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
2460
+ gate.source.unregisterQuery(LMID_QID);
2461
+ gate.source.registerQuery(LMID_QID, { name: LMID_QUERY_NAME, args: {} });
859
2462
  }
860
2463
  setResultType(qid, rt) {
861
2464
  if (this.resultTypes.get(qid) === rt)
@@ -884,16 +2487,25 @@ export class OptimisticBackend {
884
2487
  this.recomputeResultType(localQid);
885
2488
  }
886
2489
  }
887
- retainRemote(retainQid, remote, localQueryId = retainQid) {
2490
+ /** `channel` (G-iii registration-time routing): the gate the sub registers on — the qid's
2491
+ * ownership is fixed HERE, at retain time (no lazy claim; `onFrame` only asserts it). Default
2492
+ * `"daemon"`, so every channel-less caller is byte-identical to before. */
2493
+ retainRemote(retainQid, remote, localQueryId = retainQid, channel = "daemon") {
2494
+ const gate = this.requireGate(channel); // throw loudly BEFORE any sub state moves
888
2495
  const key = remoteKey(remote);
889
2496
  let sub = this.remoteSubs.get(key);
890
2497
  let isNew = false;
891
2498
  if (!sub) {
892
- sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false };
2499
+ sub = { sourceQid: retainQid, remote, refCount: 0, localQids: new Map(), hydrated: false, channel };
893
2500
  this.remoteSubs.set(key, sub);
894
2501
  this.sourceToRemote.set(sub.sourceQid, key);
895
2502
  isNew = true;
896
2503
  }
2504
+ else if (sub.channel !== channel) {
2505
+ // A (name,args) sub lives on ONE channel — a second retain naming another is a wiring bug
2506
+ // (it would split the query's frames across two cv timelines). Fail loudly.
2507
+ throw new Error(`optimistic backend: query "${remote.name}" is already retained on channel ${JSON.stringify(sub.channel)} — cannot retain it on ${JSON.stringify(channel)}`);
2508
+ }
897
2509
  sub.refCount++;
898
2510
  if (localQueryId !== undefined) {
899
2511
  sub.localQids.set(localQueryId, (sub.localQids.get(localQueryId) ?? 0) + 1);
@@ -908,8 +2520,10 @@ export class OptimisticBackend {
908
2520
  }
909
2521
  this.localToRemote.set(retainQid, key);
910
2522
  this.remoteRetainToLocal.set(retainQid, localQueryId);
2523
+ // Register on the CHANNEL's source (G-iii): the qid's frames will arrive — and buffer, release,
2524
+ // and overflow — on that channel's own §5.1 gate.
911
2525
  if (isNew)
912
- this.source.registerQuery(sub.sourceQid, remote);
2526
+ gate.source.registerQuery(sub.sourceQid, remote);
913
2527
  }
914
2528
  releaseRemote(retainQid) {
915
2529
  const key = this.localToRemote.get(retainQid);
@@ -931,7 +2545,10 @@ export class OptimisticBackend {
931
2545
  }
932
2546
  if (sub.refCount > 0)
933
2547
  return undefined;
934
- this.source.unregisterQuery(sub.sourceQid);
2548
+ // Unregister from the SAME gate's source the retain registered on. Since I-v a gate CAN be
2549
+ // removed ({@link disconnectSource}) — but never with a live sub on it ({@link
2550
+ // demoteRoomSource} validates loudly), so the daemon fallback is purely defensive.
2551
+ (this.gates.get(sub.channel) ?? this.daemonGate).source.unregisterQuery(sub.sourceQid);
935
2552
  this.sourceToRemote.delete(sub.sourceQid);
936
2553
  this.remoteSubs.delete(key);
937
2554
  return sub.sourceQid;
@@ -1009,18 +2626,146 @@ function colIndexFromSchema(schema) {
1009
2626
  }
1010
2627
  return out;
1011
2628
  }
1012
- /** Wrap the raw wasm txn as the client `MutationTx`, recording the touched tables (the client
1013
- * knows its own footprint what the pending axis derives from, §7.2). The keyed methods validate
1014
- * column names eagerly: a typo'd table or column throws with the valid names listed, at the moment
1015
- * the mutator runs.
2629
+ /** Merge a fresh invocation's write-set into a `PendingMutation`'s accumulated one (§3.2 #1, rebase
2630
+ * re-invocation): each pk's value is OVERWRITTEN with the newest image (a later invocation ran
2631
+ * against the base the rebase just replaced, so its view supersedes the earlier one), but a key
2632
+ * present only in `dest` is left alone — the same union-never-shrink rule `touched` already
2633
+ * follows (§7.2: "a re-run that no-ops must NOT shrink it"). */
2634
+ function mergeWriteSet(dest, src) {
2635
+ for (const [table, byPk] of src) {
2636
+ let d = dest.get(table);
2637
+ if (!d)
2638
+ dest.set(table, (d = new Map()));
2639
+ for (const [pkKey, rec] of byPk)
2640
+ d.set(pkKey, rec);
2641
+ }
2642
+ }
2643
+ /** The key a per-pk write source is stored under in {@link PendingMutation.writeSources}: `table`
2644
+ * + NUL + the pk-key (`stableJson(pk)`). NUL appears in neither a table name nor `stableJson`
2645
+ * output, so it is an unambiguous separator — the same key the confirm-drop hold-back reconstructs
2646
+ * from `(table, pkKey)` while iterating `writes`. */
2647
+ function writeSourceKey(table, pkKey) {
2648
+ return `${table}\u0000${pkKey}`;
2649
+ }
2650
+ /** Zip the staged-order per-write source keys (`stagedKeys`, from `trackingTx`) with the staged-order
2651
+ * `sources[]` (`commitTracked`) into `dest`, last-write-per-key winning (§5.3). Never shrinks `dest`
2652
+ * — a rebase re-invocation MERGES its fresh routing in, mirroring {@link mergeWriteSet}. The two
2653
+ * arrays align 1:1: `trackingTx` pushes exactly one key per staged change, and `commitTracked`
2654
+ * returns one source per staged change, both in staged order. */
2655
+ function mergeWriteSources(dest, stagedKeys, sources) {
2656
+ const n = Math.min(stagedKeys.length, sources.length);
2657
+ for (let i = 0; i < n; i++)
2658
+ dest.set(stagedKeys[i], sources[i]);
2659
+ }
2660
+ // --- the §3 router's pure helpers (H-iii) --------------------------------------------
2661
+ /** STRICT cell identity for the join-key no-change rule (§3 write rule #3): `===`, nothing more —
2662
+ * no coercion, no deep equality (a non-primitive join-key cell compares by reference and thus
2663
+ * conservatively fails), `NaN !== NaN` conservatively fails. Failing closed here only costs a
2664
+ * daemon route. */
2665
+ function identicalCell(a, b) {
2666
+ return a === b;
2667
+ }
2668
+ /** Whether `cond` is decidable from the KEY ALONE: every column it references — collected from
2669
+ * BOTH operand positions (under-counting could over-claim decidability; over-counting only fails
2670
+ * closed — the H-iv-a discipline) — is a pk column. A `correlatedSubquery` is never key-local. */
2671
+ // Exported for test/predicate_agreement.test.ts — the TS corner of the cross-evaluator
2672
+ // agreement corpus (rust/rindle-room-core/tests/fixtures/predicate-agreement.json). NOT part of
2673
+ // the package surface (index.ts curates exports); the corpus is the drift tripwire that keeps
2674
+ // this evaluator honest against the engine + room-gate pair it shares no code with.
2675
+ export function keyDecidable(cond, pkCols) {
2676
+ switch (cond.type) {
2677
+ case "simple": {
2678
+ if (cond.left.type === "column" && !pkCols.has(cond.left.name))
2679
+ return false;
2680
+ if (cond.right.type === "column" && !pkCols.has(cond.right.name))
2681
+ return false;
2682
+ return true;
2683
+ }
2684
+ case "and":
2685
+ case "or":
2686
+ return cond.conditions.every((c) => keyDecidable(c, pkCols));
2687
+ case "correlatedSubquery":
2688
+ return false;
2689
+ }
2690
+ }
2691
+ /** Evaluate a key-decidable `footprintWhere` on a read's pk cells — H-iii's ONE client-side
2692
+ * non-engine evaluator, DELIBERATELY MINIMAL (the TS evaluation caveat on the router block
2693
+ * comment): `simple` `=`/`!=` between a pk COLUMN and a same-primitive-type NON-NULL literal,
2694
+ * composed under and/or. The empty AND — the compiler's vacuous-true emission for an exact
2695
+ * unconstrained footprint root — evaluates `true` (load-bearing: whole-table footprints keep
2696
+ * provable reads); the empty OR is vacuous-false. Returns `undefined` = NOT EVALUABLE for
2697
+ * anything else (other ops, null on either side, cross-type comparisons, column-vs-column,
2698
+ * literal-vs-literal, non-primitive cells, correlated subqueries) — the caller fails to the
2699
+ * daemon. A non-evaluable node anywhere poisons the whole tree (no short-circuit past it):
2700
+ * partial evaluation could otherwise claim a verdict the engine's semantics might contradict. */
2701
+ // Exported for test/predicate_agreement.test.ts (see keyDecidable's note above).
2702
+ export function evalFootprintOnPk(cond, pkCells) {
2703
+ switch (cond.type) {
2704
+ case "simple": {
2705
+ if (cond.op !== "=" && cond.op !== "!=")
2706
+ return undefined;
2707
+ const col = cond.left.type === "column" ? cond.left : cond.right.type === "column" ? cond.right : undefined;
2708
+ const lit = cond.left.type === "literal" ? cond.left : cond.right.type === "literal" ? cond.right : undefined;
2709
+ if (col === undefined || lit === undefined)
2710
+ return undefined; // column-vs-column / literal-vs-literal
2711
+ if (!pkCells.has(col.name))
2712
+ return undefined; // not a pk column (keyDecidable pre-screens)
2713
+ const cell = pkCells.get(col.name);
2714
+ const value = lit.value;
2715
+ if (cell === null || value === null)
2716
+ return undefined; // no null semantics client-side
2717
+ if (typeof cell !== typeof value)
2718
+ return undefined; // no cross-type comparison semantics
2719
+ if (typeof cell !== "string" && typeof cell !== "number" && typeof cell !== "boolean")
2720
+ return undefined;
2721
+ return cond.op === "=" ? cell === value : cell !== value;
2722
+ }
2723
+ case "and": {
2724
+ let out = true;
2725
+ for (const c of cond.conditions) {
2726
+ const v = evalFootprintOnPk(c, pkCells);
2727
+ if (v === undefined)
2728
+ return undefined;
2729
+ out = out && v;
2730
+ }
2731
+ return out;
2732
+ }
2733
+ case "or": {
2734
+ let out = false;
2735
+ for (const c of cond.conditions) {
2736
+ const v = evalFootprintOnPk(c, pkCells);
2737
+ if (v === undefined)
2738
+ return undefined;
2739
+ out = out || v;
2740
+ }
2741
+ return out;
2742
+ }
2743
+ case "correlatedSubquery":
2744
+ return undefined;
2745
+ }
2746
+ }
2747
+ /** Wrap the raw wasm txn as the client `MutationTx`, capturing a pk-granular write-set as it
2748
+ * applies (`writes`, a {@link WriteSet} — table → pk-key → last-write-wins image, §3.2 #1);
2749
+ * `touched` (the pending axis's table-granular Set, §7.2) is derived by the CALLER as
2750
+ * `new Set(writes.keys())`, never populated here. The keyed methods validate column names eagerly:
2751
+ * a typo'd table or column throws with the valid names listed, at the moment the mutator runs.
2752
+ *
2753
+ * With `trapReads` (the FOLDED path, §5), the PUBLIC reads `tx.get`/`tx.row`/`tx.query` throw
2754
+ * `FoldReadError` — a folded mutator that reads state to compute its write is non-absorbing and
2755
+ * refused. The keyed writers (`update`/`upsert`/`insertIgnore`/`delete`) still read internally to
2756
+ * preserve unspecified columns / check pre-existence; that is fold-legal (the trap wraps only the
2757
+ * returned object's `get`/`row`/`query` surface, never the writers' internal probe) — unchanged
2758
+ * by H-ii, which records those probes but arms recording only where the trap never is.
1016
2759
  *
1017
- * With `trapReads` (the FOLDED path, §5), the PUBLIC reads `tx.get`/`tx.row` throw `FoldReadError`
1018
- * a folded mutator that reads state to compute its write is non-absorbing and refused. The keyed
1019
- * writers (`update`/`upsert`/`delete`) still read internally to preserve unspecified columns; that
1020
- * is column-preservation, not value-derivation, so it stays allowed. */
2760
+ * With `readLog` (recording mode, RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3.2 #2) a SIBLING
2761
+ * of `trapReads`, the two never armed together by any call site the PUBLIC `tx.get`/`tx.row`
2762
+ * push a `(table, pk, outcome, source?)` {@link ReadRecord} (per-read provenance via the
2763
+ * `provenance` probe, H-ii §3.2 #3), `tx.query` pushes its resolved AST, and (H-ii) the keyed
2764
+ * writers' pre-existence probes record through the same path. Pure capture: it changes no return
2765
+ * value, throws nothing, and is a no-op when `readLog` is omitted. */
1021
2766
  /** Apply one logical {@link MutationOp} (yielded by a shared generator mutator) onto the client's
1022
2767
  * keyed {@link MutationTx} — the same methods a plain client mutator calls directly. Column
1023
- * validation, touched-table tracking, and op collection all happen inside those methods. */
2768
+ * validation, write-set capture, and op collection all happen inside those methods. */
1024
2769
  function applyOpToTx(tx, op) {
1025
2770
  switch (op.kind) {
1026
2771
  case "insert":
@@ -1035,7 +2780,11 @@ function applyOpToTx(tx, op) {
1035
2780
  return tx.delete(op.table, op.pk);
1036
2781
  }
1037
2782
  }
1038
- function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
2783
+ function trackingTx(tx, writes, specs, localTables, onOp, trapReads = false, readLog, stagedKeys,
2784
+ /** Per-read provenance probe (H-ii §3.2 #3): called once per RECORDED present read with the
2785
+ * full-width row the read observed; meaningful only alongside `readLog` (never consulted when
2786
+ * recording is off — the fold and replay paths pass neither). */
2787
+ provenance) {
1039
2788
  const spec = (table) => {
1040
2789
  const s = specs[table];
1041
2790
  if (!s)
@@ -1069,6 +2818,26 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1069
2818
  }
1070
2819
  };
1071
2820
  const pkCells = (table, obj) => spec(table).primaryKey.map((i) => obj[spec(table).columns[i]]);
2821
+ // pk from the POSITIONAL wire shape (raw cells in schema column order) — the counterpart of
2822
+ // `pkCells` (which reads a KeyedRow) for the raw `add`/`remove`/`edit` writers below (§3.2 #1).
2823
+ const pkFromCells = (table, cells) => spec(table).primaryKey.map((i) => cells[i]);
2824
+ // Record (or overwrite) this pk's write for the invocation (§3.2 #1): last-write-wins WITHIN
2825
+ // this invocation — an add-then-edit (or edit-then-edit) of the same pk collapses to its final
2826
+ // image, matching the engine head's own semantics for that pk. The record is replaced with
2827
+ // exactly the arguments given: the CALLERS (`edit`/`remove` below, consulting `prior`) decide
2828
+ // the pre-image per the H-ii coalescing matrix on {@link WriteRecord}.
2829
+ const recordWrite = (table, pk, row, oldRow) => {
2830
+ let byPk = writes.get(table);
2831
+ if (!byPk)
2832
+ writes.set(table, (byPk = new Map()));
2833
+ const pkKey = stableJson(pk);
2834
+ // Defensive copies: the wasm binding's returned arrays are not contractually immutable/unique,
2835
+ // so a captured record must not alias a cell array the engine could later reuse or mutate.
2836
+ byPk.set(pkKey, { table, pk: [...pk], row: row ? [...row] : undefined, ...(oldRow ? { oldRow: [...oldRow] } : {}) });
2837
+ // Staged-order key trace (§5.3): pushed once per staged change, so it zips 1:1 with the staged
2838
+ // order of `commitTracked`'s `sources[]` — letting the caller learn each write's routed source.
2839
+ stagedKeys?.push(writeSourceKey(table, pkKey));
2840
+ };
1072
2841
  // A full insert row: each cell is `obj[c]`, or `null` for an omitted nullable column (design 206
1073
2842
  // §6.2); a `json` object is stringified for the engine (`toCell`). Non-nullable columns are
1074
2843
  // guaranteed present by `checkColumns(full)`.
@@ -1081,27 +2850,97 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1081
2850
  spec(table).columns.forEach((c, i) => (out[c] = cells[i]));
1082
2851
  return out;
1083
2852
  };
1084
- // Internal read used by the keyed writers below and the keyed `row` reader; never trapped (for
1085
- // the FOLD trap) but ALWAYS guarded against local reads (M1).
2853
+ // The raw, UN-recorded read primitive behind `getImpl`/`rowImpl` the M1 local guard + the txn
2854
+ // read, nothing else. Slice B deliberately kept the keyed writers (`update`/`upsert`/
2855
+ // `insertIgnore`/`delete`) on this, un-recorded ("recording is about the PUBLIC read entry
2856
+ // points"). H-ii deliberately REVERSES that: the pre-existence probe each keyed writer BRANCHES
2857
+ // on is genuine value-dependence the §3 routing proof must see — the concrete silent-drop shape
2858
+ // is `update("cards", {id:5,…})` where the client's daemon slice has the row but the room's
2859
+ // footprint lacks it: the room-side update no-ops, the commit "succeeds" with zero effects, the
2860
+ // confirm retires the entry, and the user's edit silently vanishes. The proof can only refuse
2861
+ // that route if the probe is on the record. So the keyed writers now probe through `getImpl`
2862
+ // (recorded like any public read, §3.2 #3); the FOLD trap is unaffected — it wraps only the
2863
+ // returned object's `get`/`row`/`query` surface, so keyed writers stay fold-legal and the
2864
+ // trapped path (where `readLog` is never armed) records nothing, exactly as before.
1086
2865
  const rawGet = (table, pk) => {
1087
2866
  assertNotLocal(table, "read");
1088
2867
  return tx.get(table, pk);
1089
2868
  };
2869
+ // Push one {@link ReadRecord} when recording is armed (§3.2 #2/#3): outcome from `row`'s
2870
+ // presence, per-read provenance probed with the observed FULL-WIDTH row — present reads only
2871
+ // (an absent read has no row to probe with, and records no source; see {@link ReadRecord}).
2872
+ // The `source` key is OMITTED (not set to undefined) when there is no answer, so a
2873
+ // single-domain record is byte-identical to Slice B's.
2874
+ const recordRead = (table, pk, row) => {
2875
+ if (!readLog)
2876
+ return;
2877
+ const source = row === undefined ? undefined : provenance?.(table, row);
2878
+ readLog.reads.push({
2879
+ table,
2880
+ pk: [...pk],
2881
+ outcome: row === undefined ? "absent" : "present",
2882
+ ...(source !== undefined ? { source } : {}),
2883
+ });
2884
+ };
2885
+ // The PUBLIC positional read (§3.2 #2) — and, since H-ii, the keyed writers' pre-existence
2886
+ // probe (§3.2 #3, see the `rawGet` note): `rawGet` plus a `readLog` record when recording is
2887
+ // armed. A no-op record when `readLog` is omitted — exactly `rawGet`'s behavior then.
2888
+ const getImpl = (table, pk) => {
2889
+ const result = rawGet(table, pk);
2890
+ recordRead(table, pk, result);
2891
+ return result;
2892
+ };
2893
+ // The PUBLIC keyed read (§3.2 #2), the `row` counterpart of `getImpl`.
2894
+ const rowImpl = (table, pk) => {
2895
+ checkColumns(table, pk, false);
2896
+ const pkc = pkCells(table, pk);
2897
+ const cells = rawGet(table, pkc);
2898
+ recordRead(table, pkc, cells);
2899
+ return cells ? toKeyed(table, cells) : undefined;
2900
+ };
2901
+ // The pk's existing record from THIS invocation, if any — the coalescing-matrix input for
2902
+ // `edit`/`remove` below (see {@link WriteRecord}).
2903
+ const prior = (table, pk) => writes.get(table)?.get(stableJson(pk));
1090
2904
  const add = (table, row) => {
1091
2905
  assertNotLocal(table, "write");
1092
- touched.add(table);
2906
+ recordWrite(table, pkFromCells(table, row), row);
1093
2907
  onOp?.({ table, kind: "add", row });
1094
2908
  tx.add(table, row);
1095
2909
  };
1096
2910
  const remove = (table, row) => {
1097
2911
  assertNotLocal(table, "write");
1098
- touched.add(table);
2912
+ const pk = pkFromCells(table, row);
2913
+ // The remove PRE-IMAGE (G-iii, §7.3 tombstone; the H-ii matrix on {@link WriteRecord}):
2914
+ // remove-after-edit/-remove keeps the ORIGINAL captured pre-image (the txn-entry base — the
2915
+ // net effect is a remove of the row the external world last knew, never the edited transient).
2916
+ // Otherwise (first touch, or remove-after-add) the truthful full-width row is the txn-visible
2917
+ // one — `tx.get` read BEFORE the remove stages (read-your-writes: an add of this pk earlier in
2918
+ // the SAME invocation shows through). Captured NOW so Slice H's writable-predicate evaluation
2919
+ // over it needs no migration. Falls back to the caller's asserted `row` when the pk is not
2920
+ // resident (a raw remove of an absent row) — a captured remove thus always carries a
2921
+ // full-width pre-image (the engine width-checks `holdBackAbsent`).
2922
+ const oldRow = prior(table, pk)?.oldRow ?? tx.get(table, pk) ?? row;
2923
+ recordWrite(table, pk, undefined, oldRow);
1099
2924
  onOp?.({ table, kind: "remove", row });
1100
2925
  tx.remove(table, row);
1101
2926
  };
1102
2927
  const edit = (table, oldRow, newRow) => {
1103
2928
  assertNotLocal(table, "write");
1104
- touched.add(table);
2929
+ const pk = pkFromCells(table, newRow);
2930
+ // The edit PRE-IMAGE (H-ii; the matrix on {@link WriteRecord}) — H-iii's join-key no-change
2931
+ // input, and the row the engine routes the Edit by (H-i). First touch: the txn-visible row
2932
+ // read BEFORE staging, falling back to the caller's asserted `oldRow` when the pk is not
2933
+ // resident (covers the pk-MOVING raw edit — the record is keyed by the NEW pk; the pre-image
2934
+ // carries the OLD row). Edit-after-edit: keep the FIRST pre-image (the txn-entry base).
2935
+ // Edit-after-add / edit-after-remove: the record collapses to a (re-)insert — NO pre-image
2936
+ // (the pk did not pre-exist this invocation's base).
2937
+ const p = prior(table, pk);
2938
+ const pre = p === undefined
2939
+ ? (tx.get(table, pk) ?? oldRow)
2940
+ : p.row !== undefined && p.oldRow !== undefined
2941
+ ? p.oldRow
2942
+ : undefined;
2943
+ recordWrite(table, pk, newRow, pre);
1105
2944
  onOp?.({ table, kind: "edit", row: newRow, old: oldRow });
1106
2945
  tx.edit(table, oldRow, newRow);
1107
2946
  };
@@ -1119,28 +2958,27 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1119
2958
  const ast = q.ast();
1120
2959
  for (const t of collectTables(ast))
1121
2960
  assertNotLocal(t, "read");
2961
+ readLog?.queries.push(ast);
1122
2962
  return tx.query(ast);
1123
2963
  };
1124
2964
  return {
1125
- get: trapReads ? trapped : rawGet,
2965
+ get: trapReads ? trapped : getImpl,
1126
2966
  query: trapReads ? trapped : runQuery,
1127
2967
  add,
1128
2968
  remove,
1129
2969
  edit,
1130
- row: trapReads
1131
- ? trapped
1132
- : (table, pk) => {
1133
- checkColumns(table, pk, false);
1134
- const cells = rawGet(table, pkCells(table, pk));
1135
- return cells ? toKeyed(table, cells) : undefined;
1136
- },
2970
+ row: trapReads ? trapped : rowImpl,
1137
2971
  insert: (table, row) => {
1138
2972
  checkColumns(table, row, true);
1139
2973
  add(table, toCells(table, row));
1140
2974
  },
2975
+ // The keyed writers' pre-existence probes go through `getImpl` — RECORDED reads since H-ii
2976
+ // (§3.2 #3): each writer BRANCHES on the probe, a value-dependence the routing proof must see
2977
+ // (the silent-drop rationale on `rawGet` above). Fold-legal exactly as before (the trap wraps
2978
+ // the public surface above, never these), and byte-identical when recording is off.
1141
2979
  update: (table, row) => {
1142
2980
  checkColumns(table, row, false);
1143
- const current = rawGet(table, pkCells(table, row));
2981
+ const current = getImpl(table, pkCells(table, row));
1144
2982
  if (!current)
1145
2983
  return; // rebase-friendly: the row may have vanished upstream
1146
2984
  const s = spec(table);
@@ -1151,7 +2989,7 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1151
2989
  },
1152
2990
  upsert: (table, row) => {
1153
2991
  checkColumns(table, row, true);
1154
- const current = rawGet(table, pkCells(table, row));
2992
+ const current = getImpl(table, pkCells(table, row));
1155
2993
  if (current)
1156
2994
  edit(table, current, toCells(table, row));
1157
2995
  else
@@ -1159,12 +2997,12 @@ function trackingTx(tx, touched, specs, localTables, onOp, trapReads = false) {
1159
2997
  },
1160
2998
  insertIgnore: (table, row) => {
1161
2999
  checkColumns(table, row, true);
1162
- if (!rawGet(table, pkCells(table, row)))
3000
+ if (!getImpl(table, pkCells(table, row)))
1163
3001
  add(table, toCells(table, row));
1164
3002
  },
1165
3003
  delete: (table, pk) => {
1166
3004
  checkColumns(table, pk, false);
1167
- const current = rawGet(table, pkCells(table, pk));
3005
+ const current = getImpl(table, pkCells(table, pk));
1168
3006
  if (!current)
1169
3007
  return; // rebase-friendly no-op
1170
3008
  remove(table, current);
@@ -1204,14 +3042,4 @@ function intersects(a, b) {
1204
3042
  return true;
1205
3043
  return false;
1206
3044
  }
1207
- /** Elementwise equality over positional bare-cell rows (the synthetic `__agg` rows are all
1208
- * scalar cells — group key + count — so identity comparison suffices). */
1209
- function rowsEqual(a, b) {
1210
- if (a.length !== b.length)
1211
- return false;
1212
- for (let i = 0; i < a.length; i++)
1213
- if (a[i] !== b[i])
1214
- return false;
1215
- return true;
1216
- }
1217
3045
  //# sourceMappingURL=backend.js.map