@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.d.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { Ast, Backend, BackendDevObserver, ChangeEvent, ColsMap, IsoTx, KeyedRow, Mutation, MutationGen, MutatorCtx, OptimisticSource, QueryArg, QueryId, QueryResultRow, RemoteQuery, ResultType, Schema, WireValue } from "@rindle/client";
1
+ import type { Ast, Backend, BackendDevObserver, ChangeEvent, ColsMap, Condition, IsoTx, KeyedRow, Mutation, MutationEnvelope, MutationGen, MutatorCtx, OptimisticSource, QueryArg, QueryId, QueryResultRow, RemoteQuery, ResultType, Schema, WireValue } from "@rindle/client";
2
+ import { type WritableDescriptor } from "@rindle/wasm";
3
+ import { type SystemStreamSpec } from "./system-streams.ts";
4
+ export type { SystemStreamSpec, SystemStreamTable } from "./system-streams.ts";
2
5
  /** A keyed row: column name → cell. The ergonomic shape — column names are validated against the
3
6
  * schema at runtime, so a typo throws immediately with the valid names. Re-exported from
4
7
  * `@rindle/client` (the leaf both tiers share). */
@@ -52,6 +55,178 @@ export type ClientMutator = ((tx: MutationTx, args: never) => void) | ((tx: IsoT
52
55
  * twin shares names (and possibly code), never the wire. */
53
56
  export type ClientRegistry = Record<string, ClientMutator>;
54
57
  export type { ResultType };
58
+ /** One captured write, pk-granular (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §3.2 #1: "the
59
+ * writers already receive `(table, row)`... this is pure capture, no semantic change"). `row` is
60
+ * the post-write image (positional wire cells, schema column order); `undefined` for a `remove` —
61
+ * no row survives it, and `row === undefined` stays the remove marker.
62
+ *
63
+ * `oldRow` is the PRE-IMAGE, captured for a `remove` (G-iii, §7.3 tombstone) AND — since H-ii —
64
+ * an `edit` (it feeds H-iii's join-key no-change rule, old vs new join-key cells, and mirrors the
65
+ * engine, which routes an Edit by its OLD row — the H-i handoff): the full-width row read via
66
+ * `tx.get` immediately BEFORE the write staged — read-your-writes, so a write to the same pk
67
+ * earlier in the SAME invocation shows through — falling back to the caller's asserted old row
68
+ * when the pk is not txn-visible (a raw remove/edit of an absent row; incl. the pk-MOVING raw
69
+ * edit, whose record is keyed by the NEW pk yet whose pre-image is the caller's OLD row — the row
70
+ * H-i's `provenanceOf` routes by). A captured remove/edit thus always carries a full-width
71
+ * pre-image (the engine width-checks `holdBackAbsent`; Slice H evaluates writable predicates on
72
+ * it) — with ONE exception: a record that collapses to a (re-)insert has none, see the matrix.
73
+ *
74
+ * Coalescing within one invocation is last-write-wins per pk on `row` (the final image matches
75
+ * the engine head's own semantics for that pk) with `oldRow` pinned to the TXN-ENTRY BASE — the
76
+ * H-ii matrix:
77
+ * - edit-after-add / edit-after-remove: the record collapses to a (re-)insert — post-image
78
+ * only, NO `oldRow` (the pk did not pre-exist this invocation's base; presence hold-back
79
+ * uses `row`).
80
+ * - edit-after-edit: keeps the FIRST pre-image (the txn-entry base — the chain nets to ONE
81
+ * edit from the base to the final image).
82
+ * - remove-after-edit / remove-after-remove: keeps the ORIGINAL pre-image (the first write's
83
+ * captured base), NOT the edited transient — the net effect is a remove of the row the
84
+ * external world last knew.
85
+ * - remove-after-add: keeps the txn-visible pre-image (the transient added row — the pk had
86
+ * no base, and this is the only truthful full-width row there is; G-iii pinned it).
87
+ * - add-after-remove (a re-insert): drops `oldRow` (presence hold-back uses `row`).
88
+ * Across rebase re-invocations the write-set is union-never-shrink ({@link mergeWriteSet}): a
89
+ * re-run that no-ops keeps the prior record — and its pre-image — intact. */
90
+ export interface WriteRecord {
91
+ table: string;
92
+ pk: WireValue[];
93
+ row: WireValue[] | undefined;
94
+ oldRow?: WireValue[];
95
+ }
96
+ /** The pk-granular write-set captured over ONE mutator invocation: table → pk-key (a stable-JSON
97
+ * encoding of the pk cells, {@link stableJson}) → that pk's write, LAST-WRITE-WINS within the
98
+ * invocation — an add-then-edit or edit-then-edit of the SAME pk collapses to its final image,
99
+ * matching the engine head's own semantics for that pk. Chosen (over a flat array) because the
100
+ * later routing proof needs "is pk P in the writable scope" / "did we already see this pk in this
101
+ * invocation" as cheap lookups, and rebase re-invocation needs to MERGE a fresh write-set into an
102
+ * accumulated one ({@link mergeWriteSet}) — both are Map operations, not scans.
103
+ *
104
+ * `touched` (the pre-existing table-granular `Set<string>` the pending axis reads, §7.2) is
105
+ * exactly `new Set(writeSet.keys())` — derived from this, never separately populated, so the two
106
+ * can never drift. */
107
+ export type WriteSet = Map<string, Map<string, WriteRecord>>;
108
+ /** Whether a recorded point read (`tx.get`/`tx.row`) found a row. */
109
+ export type ReadOutcome = "present" | "absent";
110
+ /** One recorded point read, pk-granular (§3.2 #2). Recording-mode only — see {@link ReadLog}.
111
+ * Since H-ii this covers BOTH the public reads (`tx.get`/`tx.row`) and the keyed writers'
112
+ * internal pre-existence probes (§3.2 #3 — see the `rawGet` note in {@link trackingTx}). */
113
+ export interface ReadRecord {
114
+ table: string;
115
+ pk: WireValue[];
116
+ outcome: ReadOutcome;
117
+ /** Per-read provenance (H-ii, §3.2 #3): the engine's `provenanceOf` answer AT THE MOMENT OF THE
118
+ * READ — which source ("daemon" / a room key) served the row the mutator saw. Live pre-commit
119
+ * state: an open txn's staged writes are not consulted (H-i), so a PRESENT read of a pk this
120
+ * txn itself created reports `undefined` — recorded honestly, key omitted (the router treats
121
+ * self-reads via write-set membership, not provenance). ABSENT reads carry no source (the
122
+ * mutator saw no row; there is no full-width row to probe with — a pk this txn staged-removed
123
+ * thus also records `undefined`, again a write-set-membership case for the router). Probed only
124
+ * on a PROMOTED table ({@link promotedTables}): a never-promoted (Collapsed) table is
125
+ * daemon-owned by construction, so a pure single-domain app pays zero wasm calls per read and
126
+ * `undefined` is still correct (everything is daemon-owned). One probe call per recorded
127
+ * present read, at read time. */
128
+ source?: string;
129
+ }
130
+ /** The read-log captured over ONE mutator invocation when recording is armed (RINDLE-REALTIME-
131
+ * QUERY-ENABLEMENT-DESIGN.md §3.2 #2): every point read (`reads` — the public `tx.get`/`tx.row`
132
+ * and, since H-ii, the keyed writers' internal pre-existence probes) plus every resolved query AST
133
+ * (`queries`, from `tx.query`). A SIBLING of the folded read TRAP (`FoldReadError` below) — the
134
+ * trap arms on the folded path and throws before any read completes (recording never runs there);
135
+ * recording arms on the ordinary (non-folded) prediction run and never throws. Pure capture: which
136
+ * row/predicate a read is actually PROVEN covered by (§3.1's table) is a later slice's job. */
137
+ export interface ReadLog {
138
+ reads: ReadRecord[];
139
+ queries: Ast[];
140
+ }
141
+ /** One promoted `(sourceKey, table)`'s client-held routing spec — THE per-table routing record the
142
+ * §3 prove-or-slow-path router reads (H-iii). Recorded by {@link OptimisticBackend.promoteRoomTable}
143
+ * in the same breath as the engine attach, from the lease's `RealtimeLeaseTableSpec` (the
144
+ * api-server's `RoomTableSpec`, one compiler with the boot wire).
145
+ *
146
+ * - `writable`: whether the room may write this table AT ALL — `true` iff the registered engine
147
+ * {@link WritableDescriptor} is not `none` (a context/"followed" table is `none`; the daemon
148
+ * stays write-authoritative for it, §2.2). Rule #1 of the write proof.
149
+ * - `joinKeyCols`: the correlation columns the footprint binds on this table — the "room mutators
150
+ * never change join keys" input (an edit with a moved join-key cell would re-parent the row
151
+ * across the footprint's correlation and is refused room-routing). Deliberately client-side —
152
+ * they never cross the wasm {@link WritableDescriptor} ABI (the room GATE enforces them
153
+ * engine-side, H-iv).
154
+ * - `where`: the lease's row-local writable predicate (advisory here — the router evaluates the
155
+ * ENGINE's compiled copy via `writableMatches`, never this one; kept for introspection parity
156
+ * with the wire).
157
+ * - `footprintWhere`: the EXACT footprint-membership predicate (H-iv-b: root-node-only, lossless
158
+ * extraction; the vacuous-true `{type:"and",conditions:[]}` for an exact unconstrained root;
159
+ * ABSENT for child/correlated tables). The read proof's pk-membership test input — see
160
+ * {@link OptimisticBackend.deriveDomain}. */
161
+ export interface RoomTableRouting {
162
+ writable: boolean;
163
+ joinKeyCols: string[];
164
+ where?: Condition;
165
+ footprintWhere?: Condition;
166
+ }
167
+ /** The per-table spec {@link OptimisticBackend.promoteRoomTable} receives beside the engine
168
+ * descriptor (H-iii spec plumbing) — the wire-facing half of {@link RoomTableRouting}
169
+ * (`writable` is derived from the descriptor's kind, never passed separately). */
170
+ export interface RoomTableRoutingSpec {
171
+ joinKeyCols?: readonly string[];
172
+ where?: Condition;
173
+ footprintWhere?: Condition;
174
+ }
175
+ /** The §3 router's Q6 measurement hook ({@link OptimisticBackend.__inspectDomains}`().routing`):
176
+ * how often derivation succeeded (per derived room) and why it fell back to the daemon (per
177
+ * failure reason). `reasons` counts PER-CANDIDATE proof failures plus the terminal reasons
178
+ * (`no-candidates` / `tx-query` / `ambiguous`) — with multiple candidate rooms one derivation can
179
+ * bump several reason counters (one per failing candidate), and a candidate's failure is counted
180
+ * even when a sibling candidate ultimately proved. Explicit-policy pins bump NOTHING (the router
181
+ * never ran). */
182
+ export interface RoutingInspect {
183
+ /** Successful DERIVED routes, per room key. */
184
+ derived: Record<string, number>;
185
+ /** Derivation failures, per {@link RoutingFailureReason}. */
186
+ reasons: Record<string, number>;
187
+ }
188
+ /** Why a §3 derivation (or one candidate's proof) fell to the daemon — the {@link RoutingInspect}
189
+ * counter keys. */
190
+ export type RoutingFailureReason =
191
+ /** No connected room gate with a promoted table — the single-domain fast path. */
192
+ "no-candidates"
193
+ /** The invocation ran `tx.query` — a declarative read is not room-provable client-side
194
+ * (rindle-cover is native-only BY DESIGN), so the whole derivation fails unconditionally. */
195
+ | "tx-query"
196
+ /** Two or more candidate rooms both proved. Principled disambiguation is a §9.2 (multi-room
197
+ * clients + budgets) NON-goal, deferred past Slice J; routing slow is always sound (the §3.3
198
+ * gate is the contract). Reachable essentially only for zero-write mutations — real writes
199
+ * can't be writable in two rooms (§2.2 exactly-one-writer per doc). */
200
+ | "ambiguous"
201
+ /** A write touched a table not promoted for the candidate, or promoted `writable: none`
202
+ * (context) — the room may not write it at all. */
203
+ | "write-unwritable-table"
204
+ /** `writableMatches` refused the write's post-image (add/edit) or pre-image (remove). */
205
+ | "write-scope-miss"
206
+ /** An edit moved a join-key cell (old vs new differ on a `joinKeyCols` column). */
207
+ | "write-join-key-change"
208
+ /** The engine's provenance for the write's probe row names a DIFFERENT room. */
209
+ | "write-cross-room-provenance"
210
+ /** A recorded read was served by a DIFFERENT room. */
211
+ | "read-cross-room"
212
+ /** A read needed the pk-membership test but the table has no `footprintWhere` (child /
213
+ * correlated / inexact extraction / never-promoted table). */
214
+ | "read-no-footprint-where"
215
+ /** `footprintWhere` references a non-pk column — not decidable from the key alone. */
216
+ | "read-not-key-decidable"
217
+ /** `footprintWhere` is key-decidable but outside the deliberately-minimal client evaluator
218
+ * (an op other than `=`/`!=`, a null/type-mixed comparison, column-vs-column, …). */
219
+ | "read-not-evaluable"
220
+ /** `footprintWhere` evaluated FALSE on the read's pk — the room provably never holds it. */
221
+ | "read-outside-footprint"
222
+ /** NOT a derivation failure (H-v): a routed mutation came back `mutationOutcome
223
+ * {kind:"deopt"}` — the room GATE refused it at commit and the client re-enqueued it onto the
224
+ * daemon. Counted per processed deopt frame so Q6's picture is complete: derived-and-deopted
225
+ * routes are visible beside derived successes (a rising `deopt` count against a rising
226
+ * `derived` count means the client proof and the gate disagree — a compiler/evaluator skew
227
+ * worth investigating; each one costs a room round-trip + a burnt room mid, never
228
+ * correctness). */
229
+ | "deopt";
55
230
  /** A virtual-clock seam for the fold debounce/maxWait timers (FOLDED-MUTATIONS-DESIGN §9): the
56
231
  * oracle injects a deterministic scheduler; production defaults to real timers + `Date.now`. */
57
232
  export interface FoldClock {
@@ -69,6 +244,15 @@ export interface FoldOptions {
69
244
  /** Hard cap so a never-idle drag still persists periodically (trailing throttle). Unbounded if
70
245
  * omitted — an idle gap of `debounceMs` is then the only thing that flushes. */
71
246
  maxWaitMs?: number;
247
+ /** §9.3 room-aware cadence. When this fold's write ROUTES INTO A ROOM (a collaborator is live on
248
+ * the shared head), flush at this (short) interval instead of `debounceMs`/`maxWaitMs`, so the
249
+ * intermediate frames STREAM to the room rather than collapsing to last-value-wins — the pen is
250
+ * watched, so its growth matters. OFF the room (solo / daemon-served) this is ignored and the
251
+ * caller's `debounceMs` collapse governs. `0` ⇒ per-frame (never coalesce while in a room); a
252
+ * small value (e.g. 40ms) animates while still capping the write rate. Absent ⇒ same cadence
253
+ * room or not (today's behavior). The room decision is probed from the write-set at the fold
254
+ * window's first invoke; it stays fixed for that window. */
255
+ roomDebounceMs?: number;
72
256
  /** Keep deferring across overlapping non-fold writes for maximum economy, accepting the §4.2
73
257
  * read-dependent reorder snap. Default `false` (flush-on-enqueue — correct-and-boring). */
74
258
  deferAcrossWrites?: boolean;
@@ -80,6 +264,26 @@ export interface FoldHandle {
80
264
  flush(): void;
81
265
  readonly mid: Promise<number>;
82
266
  }
267
+ /** One I-iv doorbell event ({@link OptimisticBackend.onScopeSessions}, §4.1): a release folded
268
+ * scope-session rows for `scope`, and `others` is the count of OTHER clients' unexpired sessions
269
+ * there — {@link OptimisticBackend.otherScopeSessions} evaluated at fold time (the same one rule,
270
+ * on the injectable {@link FoldClock}, so a virtual-clock harness gets deterministic verdicts).
271
+ * The consumer (client.ts) triggers its one debounced re-lease on the 0→≥1 transition; expired
272
+ * and own-clientID rows never count, so a solo client's own row can never ring its own bell. */
273
+ export interface ScopeSessionsEvent {
274
+ scope: string;
275
+ others: number;
276
+ }
277
+ /** The I-v stuck-downgrade event ({@link OptimisticBackend.onDowngradeStuck}): the ghost's fence
278
+ * is satisfied but these SENT room-domain mids never resolved (an entry that never reached the
279
+ * room — sent-but-undelivered when the socket died — is undecidable in general, §7.5). The ghost
280
+ * HOLDS (fail LOUD, never silent; no timeout-retire is invented) and the mids are surfaced once,
281
+ * actionably. */
282
+ export interface DowngradeStuckEvent {
283
+ sourceKey: string;
284
+ doc: string;
285
+ mids: number[];
286
+ }
83
287
  export interface OptimisticBackendOptions {
84
288
  /** Stable per-client identity for the upstream envelopes (§8.1). */
85
289
  clientID: string;
@@ -93,6 +297,23 @@ export interface OptimisticBackendOptions {
93
297
  /** Virtual-clock seam for the fold debounce timers (FOLDED-MUTATIONS-DESIGN §9). Defaults to
94
298
  * real `setTimeout`/`clearTimeout`/`Date.now`; the fold oracle injects a deterministic clock. */
95
299
  clock?: FoldClock;
300
+ /** The explicit confirming-stream OVERRIDE (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1 /
301
+ * §3). Since H-iii the live default is the §3 prove-or-slow-path DERIVATION
302
+ * ({@link OptimisticBackend.deriveDomain}): a configured policy returning a string pins that
303
+ * domain VERBATIM — no proof runs (the test suites and the dual-source e2e pin by name);
304
+ * returning `undefined` (or configuring no policy) derives the domain from the prediction run's
305
+ * write/read capture against the connected room gates' routing table. With no room gate
306
+ * connected the derivation short-circuits to `"daemon"` — every single-domain app is
307
+ * byte-for-byte as before. */
308
+ domainPolicy?: (name: string, args: unknown) => string | undefined;
309
+ /** A FINAL (authz/validation) mutation rejection's reason surface — the room plane's twin of the
310
+ * HTTP mutate route's `onRejected` (H-v; the H-iv-b `mutationOutcome {kind:"rejected"}` frame).
311
+ * The prediction's snap-back is NOT this callback's job: the room burns the mid and its lmid
312
+ * release drops the entry exactly as a daemon-path rejection does (processed-as-no-op) — this
313
+ * is where the REASON reaches the app, same contract as the queue's callback. Also invoked when
314
+ * a DEOPT's fresh re-invocation (the already-retired arm) throws — that mutation is dead on the
315
+ * current base with no stream left to confirm it, the closest thing to a rejection there is. */
316
+ onRejected?: (envelope: MutationEnvelope, reason: string) => void;
96
317
  }
97
318
  /** One folded entry's debounce window, for the timeline's fold drill-down (§4.1). */
98
319
  export interface FoldInspect {
@@ -115,6 +336,13 @@ export interface PendingInspect {
115
336
  args: unknown;
116
337
  /** Tables this mutator touched at its last (re)invocation — the pending-axis basis (§7.2). */
117
338
  tables: string[];
339
+ /** The pk-granular write-set captured at this entry's LAST invocation (RINDLE-REALTIME-QUERY-
340
+ * ENABLEMENT-DESIGN.md §3.2 #1), flattened from the {@link WriteSet} map for inspection — one
341
+ * entry per `(table, pk)` currently held. Pure capture; no routing consumer yet. */
342
+ writes: WriteRecord[];
343
+ /** The read-log captured at this entry's LAST *recorded* invocation (§3.2 #2). Empty for a
344
+ * folded entry — the read TRAP arms there, not recording (see {@link PendingMutation.reads}). */
345
+ reads: ReadLog;
118
346
  /** Present iff this entry is a folded (debounced) write. */
119
347
  fold?: FoldInspect;
120
348
  }
@@ -156,6 +384,9 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
156
384
  * its width check. */
157
385
  private readonly colCounts;
158
386
  private readonly colIndex;
387
+ /** Per-table pk column indices — held so `connectSource` can build a fresh per-source
388
+ * `NormalizedSync` with the same layout the daemon's uses. */
389
+ private readonly pkCols;
159
390
  /** The client's OWN typed per-table schemas + the reserved lmid table — the fixed base
160
391
  * of the expected-schema set (CRIT#4 validation). Synthetic agg tables are appended as
161
392
  * queries arrive (`ensureSyntheticTables`). */
@@ -174,6 +405,13 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
174
405
  * pending delta `displayed = server_base ⊕ local_pending_delta` is applied from. */
175
406
  private readonly overlay;
176
407
  private handler;
408
+ private catchUpQids;
409
+ /** Newly-hydrated qids whose reconcile ACTUALLY emitted a (catch-up-stamped) batch — recorded by the
410
+ * local-event forwarder alongside {@link catchUpQids}. After the reconcile, any newly-hydrated qid
411
+ * NOT in here folded nothing (0 rows, or its result already present via a sibling → 0 net muts, or
412
+ * the reconcile was skipped), so `onProgress` sends it an explicit empty catch-up — else its SSR
413
+ * seed would never retire (the view freezes). Non-null only for the reconcile's duration. */
414
+ private catchUpEmitted;
177
415
  /** The Store's commit-boundary handler ({@link Backend.onCommitBoundary}), forwarded from the
178
416
  * local engine's `dispatch` brackets so the Store folds every affected view before notifying any
179
417
  * subscriber (cross-view-atomic notification). All this backend's data deltas originate from the
@@ -181,18 +419,139 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
181
419
  private boundaryHandler;
182
420
  private readonly devObservers;
183
421
  private pendingMutations;
422
+ /** The next mid to deal, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1): a client
423
+ * writing through room + daemon concurrently must not alias one lmid counter. Seeded with the
424
+ * `"daemon"` stream at 1; a domain absent from the map starts at 1. In the single-domain
425
+ * configuration only `"daemon"` is ever touched, so the sequence is byte-for-byte as before. */
184
426
  private nextMid;
427
+ /** The client-global deal counter behind {@link PendingMutation.seq}: one sequence across ALL
428
+ * domains, bumped whenever any domain's mid is dealt. The replay order (mids are per-domain and
429
+ * incomparable across domains — see the `seq` field doc). */
430
+ private dealSeq;
431
+ /** The explicit confirming-stream override (§7.1/§3) — see
432
+ * {@link OptimisticBackendOptions.domainPolicy}. `undefined` from it ⇒ H-iii derivation. */
433
+ private readonly domainPolicy;
434
+ /** The final-rejection reason surface ({@link OptimisticBackendOptions.onRejected}). */
435
+ private readonly rejectedHandler;
436
+ /** Processed `(domain, mid)` outcome frames (H-v) — the deopt handshake's idempotence guard: a
437
+ * duplicate frame (the original plus a reconnect re-send's re-answer, or two re-answers across
438
+ * two reconnects) must not double-invoke. Needed precisely because a deopt frame can arrive for
439
+ * an ALREADY-RETIRED mid (the replay gotcha) — "no matching entry" alone cannot distinguish
440
+ * "handle it fresh" from "already handled". Per-domain FIFO, capped like the shell's
441
+ * recorded-outcome map ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}); past the cap a duplicate of
442
+ * an evicted mid would be re-processed — the same bounded-window trade the shell makes, and it
443
+ * takes 512 interleaving non-applied outcomes on one domain to open it. */
444
+ private readonly outcomesProcessed;
445
+ /** THE routing table (H-iii §3): per connected room `sourceKey` → per promoted table → its
446
+ * client-held {@link RoomTableRouting}. Written ONLY by {@link promoteRoomTable} (same breath
447
+ * as the engine attach + {@link promotedTables}); read by {@link deriveDomain} and — via
448
+ * {@link roomTablesFor} — the client's `__realtimeInspect` bookkeeping. */
449
+ private readonly roomRouting;
450
+ /** Q6 counters ({@link RoutingInspect}): successful derivations per room. */
451
+ private readonly routingDerived;
452
+ /** Q6 counters ({@link RoutingInspect}): derivation failures per {@link RoutingFailureReason}. */
453
+ private readonly routingReasons;
185
454
  /** The live fold entries, by fold key `${name}\0${identityJSON}` — at most one per key
186
455
  * (FOLDED-MUTATIONS-DESIGN §8). Insertion order is creation order (the drain/flush tiebreak). */
187
456
  private readonly folds;
188
457
  /** The fold debounce clock (real timers by default; the oracle injects a virtual one). */
189
458
  private readonly clock;
190
- /** The high-water confirmed mutation id, folded from the lmid system query's
191
- * RELEASED ops (lmid-as-data) never from a frame. */
192
- private confirmedLmid;
193
- private buffer;
194
- private nextSeq;
195
- private appliedCv;
459
+ /** The high-water confirmed mutation id, PER DOMAIN (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
460
+ * §7.2 per-domain confirm-drop): an entry with `mid <= watermark[entry.domain]` has been
461
+ * confirmed. The `"daemon"` domain is folded from the lmid system query's RELEASED ops
462
+ * (lmid-as-data) — never from a frame; a room domain will fold from its own lmid stream (later
463
+ * slice). Seeded with `"daemon"` at 0; the daemon scalar `confirmedLmid` (devtools) is
464
+ * `watermark.get("daemon")`. */
465
+ private watermark;
466
+ /** The per-source coherence gates (§5.1), by source key. Seeded with the daemon gate at
467
+ * construction; a room channel attaches later (`connectSource`). Single-domain: one entry,
468
+ * and every gate-generalized path degenerates to the old single-buffer code. NOT the same
469
+ * space as {@link watermark}/{@link nextMid}: a DOMAIN can confirm with no gate connected
470
+ * (the `__testRelease` seam); a gate's `key` names the domain its lmid stream folds into. */
471
+ private readonly gates;
472
+ /** The daemon's gate — the always-present channel (constructor-attached). The devtools
473
+ * scalars (`__inspect`) read it directly; its `sync` IS {@link sync} (the agg overlay and
474
+ * synthetic tables are daemon-tracked by design). */
475
+ private readonly daemonGate;
476
+ /** Tables promoted to a MERGED multi-source engine (a room source attached). Written by the one
477
+ * promotion seam ({@link promoteRoomTable}; the test-named {@link __addRoomSource} and Slice
478
+ * G-v's lease-driven promotion from `RoomTableSpec[]` both flow through it). Read by the §7.3
479
+ * hold-back trigger in
480
+ * {@link applyRelease}: parking on a never-promoted (Collapsed) table is SKIPPED — inert anyway
481
+ * today (`rewind_collapsed` never consults `held_back`), and a live hazard if the table later
482
+ * promotes (a stale Collapsed-era entry turns overlay-first-visible and pins forever). Slice H's
483
+ * prove-or-slow-path routing keeps the real scenario — a room-confirmed mutation writing a
484
+ * Collapsed table — impossible; this gate covers the window until then. */
485
+ private readonly promotedTables;
486
+ /** The §301 pin registry ({@link EchoFencePin}): every parked §7.3 hold-back's delivery-fence
487
+ * inputs, keyed `${table}\0${sourceKey}\0${pkKey}`. Written in the same breath as the
488
+ * `holdBack`/`holdBackAbsent` park; read by {@link dropEchoFencePins} at the tail of every
489
+ * release. Empty on every single-domain client — the drop pass is then a structural no-op. */
490
+ private readonly pins;
491
+ /** The §301 direction-A fence input: per room domain, the highest lmid the DAEMON-CARRIED
492
+ * ledger rows have folded ({@link foldSystemFrames} step 2 — and ONLY that path: the room
493
+ * socket's own lmid stream confirms long before the flush reaches the daemon, so it folds
494
+ * into the shared {@link watermark} but never here, 301 §1.1). When this covers a pin's mid,
495
+ * the same coherent release (or an earlier one) folded the flush data that carried it into
496
+ * the daemon baseline — the fence. */
497
+ private readonly daemonCarriedLmid;
498
+ /** The §301 §2.4 boot rule's inputs: the daemon boot ids this client has OBSERVED, in order
499
+ * (id → ordinal), plus the current one. Boot ids are opaque — ordering is the client's own
500
+ * observation ({@link OptimisticSource.onBootId} on the daemon gate). Direction-B pins stamp
501
+ * the current boot; a room advertising a LATER-observed boot proves absorption (its
502
+ * re-snapshot came from a daemon state that durably includes the confirmed write). */
503
+ private readonly daemonBootOrdinals;
504
+ private daemonBootId;
505
+ /** The per-read provenance probe `invoke` hands `trackingTx` when recording is armed (H-ii,
506
+ * §3.2 #3): the engine's `provenanceOf` — the VISIBLE overlay-first winner for the row's pk,
507
+ * read from LIVE pre-commit state (an open txn's staged writes are not consulted; H-i). Gated
508
+ * on PER-TABLE {@link promotedTables} membership — the cheapest existing signal, and the
509
+ * correct one: provenance is an ENGINE-merge question, not a channel question ({@link gates}
510
+ * can hold a connected-but-unpromoted room feed, and the oracle harness promotes with no gate),
511
+ * and a never-promoted (Collapsed) table is daemon-owned by construction. So a pure
512
+ * single-domain app pays ZERO wasm calls per recorded read; the recorded `source` is then
513
+ * `undefined`, which is also correct — everything is daemon-owned (see {@link ReadRecord}). */
514
+ private readonly readProvenance;
515
+ /** System retains by source qid ({@link retainSystemQuery}): a subscription with NO store view
516
+ * and NO user-visible table — its frames buffer on its gate exactly like {@link LMID_QID}'s and
517
+ * fold at RELEASE time ({@link foldSystemFrames}), never entering the sync layer or the local
518
+ * engine. The spec names which system table the qid serves and the scope/doc it was minted for
519
+ * (the fold's row filter). Empty on every non-lifecycle client — every partition below is then
520
+ * a structural no-op and the release path is byte-identical to before. */
521
+ private readonly systemQids;
522
+ /** The §4.2 fence state: room doc → highest `flush_seq` delivered through the daemon plane
523
+ * (monotone max-fold; a remove never regresses it). Slice I-v's ghost-drop consumer — I-iii
524
+ * only maintains + exposes it (`__inspectDomains().lifecycle`). */
525
+ private readonly roomWatermarks;
526
+ /** The §4.1 occupancy state: scope → (client_id → expires_at) from the doorbell stream. Slice
527
+ * I-iv's doorbell consumer (the 1→2 re-lease reaction) — I-iii only maintains + exposes it.
528
+ * A snapshot REPLACES the scope's map (authoritative re-hydrate); a batch folds add/edit/remove
529
+ * incrementally (the age-out sweep's deletes arrive as removes). */
530
+ private readonly scopeSessions;
531
+ /** The I-iv doorbell event sink ({@link onScopeSessions}) — fired once per scope a release's
532
+ * scope-session fold touched, AFTER the whole release applied. Default no-op: a client that
533
+ * never registers (no lifecycle plane) pays nothing. */
534
+ private scopeSessionsHandler;
535
+ /** Deferred old-channel row GC for in-flight upgrade retargets ({@link retargetRemoteQuery}):
536
+ * sub sourceQid → the channel it left. The rows the OLD gate's sync holds for the qid stay
537
+ * visible (merge: daemon tier) until the sub's first snapshot RELEASES on its new room channel
538
+ * ({@link flushRetargetGc}) — dropping them at retarget time would emit net removes ahead of
539
+ * the room's re-adds, the flicker the two-phase cutover exists to avoid. Doubles as the
540
+ * wrong-channel GRACE window in {@link onFrame}: a frame already in flight from the old
541
+ * channel when the sub moved is stale, not a wiring bug. Empty on every non-upgrade client —
542
+ * every consultation below is then a structural no-op. */
543
+ private readonly pendingRetargetGc;
544
+ /** The §4.2 GHOSTS (Slice I-v): demoted room sources awaiting their watermark fence, by
545
+ * sourceKey. Written only by {@link demoteRoomSource}; evaluated after every release
546
+ * ({@link evaluateGhosts}) and dropped by {@link dropGhost} once the fence clears with no
547
+ * sent room-domain pending left. Empty on every non-downgrade client — the per-release
548
+ * evaluation is then a structural no-op. */
549
+ private readonly ghosts;
550
+ /** The I-v stuck-downgrade surface ({@link onDowngradeStuck}) — fired AT MOST ONCE per ghost
551
+ * when its fence is satisfied but sent room-domain mids remain unresolved (§7.5: they retire
552
+ * only through outcome resolution; the ghost holds rather than inventing a timeout-retire).
553
+ * Default no-op. */
554
+ private downgradeStuckHandler;
196
555
  private readonly asts;
197
556
  /** Per query: the base tables its result can draw from (from the AST tree). */
198
557
  private readonly queryTables;
@@ -210,8 +569,32 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
210
569
  * touches its tables. Cached so `onPending` fires only on transitions (invoke ↔ confirm). */
211
570
  private readonly pendingState;
212
571
  private pendingHandler;
572
+ /** The local-persistence write-through tap (`207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.1):
573
+ * {@link writeLocal} invokes it post-commit; {@link applyLocalReplica} deliberately does not. */
574
+ private localWriteObserver;
213
575
  constructor(schema: Schema<S>, source: OptimisticSource, registry: ClientRegistry, opts: OptimisticBackendOptions);
214
- registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery): void;
576
+ /** Wire one authority channel into its own coherence gate (§5.1): every frame the channel
577
+ * delivers buffers on THIS gate's cv timeline, its progress frames release THIS buffer, its
578
+ * restart resets THIS gate alone, and its reserved lmid stream folds into `watermark[key]`.
579
+ * Validates each server hello against our OWN typed schema → reject a schema skew (CRIT#4);
580
+ * the reserved lmid table is part of the expected set so the system query's hello passes, and
581
+ * synthetic agg tables join the set as queries register them. */
582
+ private attachGate;
583
+ /** Attach a SECOND authority channel (§5.1) — the seam Slice G's room upgrade calls with the
584
+ * ws-backed room feed. Rooms speak the daemon protocol verbatim (§2.4: the client cannot tell
585
+ * a room from the daemon), so the argument is a full {@link OptimisticSource} — exactly what
586
+ * `@rindle/remote` builds from `{roomUrl, leaseToken}`. The channel buffers/releases on its
587
+ * own cv timeline (an independent §5.1 gate: coherent within, eventual across) and its
588
+ * reserved lmid stream folds into `watermark[sourceKey]` — so `sourceKey` must equal the
589
+ * `domainPolicy` name for the mutations this authority confirms. The converse is NOT required:
590
+ * a domain may exist with no connected gate (`__testRelease` drives confirms gate-less); the
591
+ * live production path stays daemon-only until G calls this. */
592
+ connectSource(sourceKey: string, source: OptimisticSource): void;
593
+ /** `channel` (G-iii registration-time routing) names the authority channel the remote sub
594
+ * registers on — a `connectSource`d gate key; default `"daemon"` (every existing caller is
595
+ * byte-identical). Slice G-v threads the lease's `realtime.sourceKey` here. Validated FIRST
596
+ * (like the E3 check below): a bad channel must throw before any per-query state is recorded. */
597
+ registerQuery(qid: QueryId, ast: Ast, remote?: RemoteQuery, channel?: string): void;
215
598
  /** Register every synthetic aggregate table `ast` needs that we haven't seen yet: on the
216
599
  * local engine (which auto-tracks it for the optimistic rebase loop), on `NormalizedSync`
217
600
  * (so its rows refcount/GC by group key), and into the source's expected-schema set (so
@@ -225,8 +608,145 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
225
608
  * `unregisterTable` frees it (the engine refuses otherwise). */
226
609
  private releaseSyntheticTables;
227
610
  unregisterQuery(qid: QueryId): void;
228
- retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast): void;
611
+ /** `channel` as in {@link registerQuery} (G-iii): the gate the remote sub registers on; default
612
+ * `"daemon"`. This is the split-retain seam G-v's resolve-then-register drives — resolve the
613
+ * lease, learn `realtime.sourceKey`, `connectSource` it, then retain the query on that channel.
614
+ * Validated FIRST so a bad channel throws before any synthetic-table refcount moves. */
615
+ retainRemoteQuery(qid: QueryId, remote: RemoteQuery, localQueryId?: QueryId, ast?: Ast, channel?: string): void;
229
616
  releaseRemoteQuery(qid: QueryId): void;
617
+ /** The Slice I-iv upgrade retarget (§4.1 "Retarget" / the doorbell reaction): move a LIVE
618
+ * (name, args) sub — every retain of it and every local view it feeds, wholesale — from the
619
+ * channel it lives on onto `sourceKey`'s (already-`connectSource`d, already-promoted) room
620
+ * channel, WITHOUT the view ever dropping its rows. Returns the sub's wire `sourceQid` (the
621
+ * identity the client's renewal loop re-subscribes with).
622
+ *
623
+ * Why a dedicated primitive: the one-channel-per-(name,args) invariant ({@link retainRemote}'s
624
+ * loud throw) is correct — a sub's frames must never split across two cv timelines — so the
625
+ * upgrade cannot simply retain a second sub on the room and release the daemon one; and the
626
+ * naive release-then-retain order GCs the daemon sync's rows synchronously (net removes emit,
627
+ * the view flashes empty) a full ws round trip before the room's seq-0 snapshot refills it.
628
+ * The cutover is therefore TWO-PHASE around the room's first release:
629
+ *
630
+ * 1. NOW (here): unsubscribe the old channel's wire sub, sweep its still-buffered frames for
631
+ * this qid (their cv timeline continues without the sub — the hello-supersession
632
+ * precedent), flip `sub.channel`, re-arm `sub.hydrated` (the room's own snapshot is the
633
+ * cutover point), and register on the room source (its resolver presents the handed
634
+ * roomToken). The old gate's SYNC rows are deliberately NOT dropped: they keep the view's
635
+ * rows visible through the window (merge: the daemon-tier holder). Local views keep their
636
+ * `hydrated` membership — the view stays `complete` on its authoritative daemon rows.
637
+ * 2. AT THE ROOM'S FIRST RELEASED SNAPSHOT ({@link flushRetargetGc}): the room source now
638
+ * holds the footprint rows (folded via `serverBatchBegin(sourceKey)` — value-equal rows
639
+ * re-hydrate as a net-zero diff, RT §3.4 per channel), so the deferred
640
+ * `dropQuery`+reconcile on the OLD gate flips each pk's winner daemon→room value-equal:
641
+ * net-zero again. No emission carries a disappearance at any point.
642
+ *
643
+ * Idempotent per target channel: a sub already on `sourceKey` returns immediately (the
644
+ * double-doorbell / re-entrancy guard — one retarget per (query, sourceKey), mirroring
645
+ * {@link promoteRoomTable}'s caller-side per-(sourceKey, table) idempotence). Validates before
646
+ * mutating: a throw here leaves the sub fully daemon-attached (the client's fail-open). */
647
+ retargetRemoteQuery(remote: RemoteQuery, sourceKey: string): QueryId;
648
+ /** Phase 2 of {@link retargetRemoteQuery}, run at the end of every gate release: once a
649
+ * retargeted sub's first snapshot has RELEASED on its new channel (`sub.hydrated` re-armed at
650
+ * retarget, re-set by {@link markSubHydrated} inside this very release), drop the qid's rows
651
+ * from the OLD gate's sync and reconcile them out — after the room's rows are already applied,
652
+ * so the winner flip is value-equal (net-zero; see the phase table above). A sub torn down
653
+ * mid-window was already swept by `releaseRemoteQuery`/`unregisterQuery` (which delete the
654
+ * record); a vanished record here is pruned defensively. */
655
+ private flushRetargetGc;
656
+ /** The I-v downgrade orchestration primitive (§4.2/§7.4): retire room `sourceKey` behind the
657
+ * watermark fence. The caller has ALREADY retargeted every live sub off the channel
658
+ * ({@link retargetRemoteQuery} room→daemon — validated loudly below) and holds the fence from
659
+ * the api-server's downgrade response (`finalFlushSeq` = the room's last COMMITTED flush seq;
660
+ * `doc` keys the §4.2 watermark fold, {@link roomWatermarks}). Steps, in order:
661
+ *
662
+ * 1. **De-candidacy NOW**: the sourceKey's {@link roomRouting} entries are removed, so
663
+ * {@link deriveDomain} stops proposing the room (zero candidates ⇒ `"daemon"`). Unsent
664
+ * pending (`mid === null`) re-routes for free — a fold's flush re-derives from the live
665
+ * candidate set (§7.5 rule 1). The engine's frozen scope still ANSWERS (`writableMatches`
666
+ * / `provenanceOf` — freezing gates serving, not the scope definition); routing is
667
+ * TS-gated here.
668
+ * 2. **Freeze** the WRITABLE promoted tables (`freezeSource`): the room slice becomes the
669
+ * §4.2 ghost — wins-if-present in the merge (D4), so its rows (at-or-ahead of the daemon
670
+ * until the flush echoes) stay visible while the slice accepts nothing. Context tables
671
+ * (`writable: false`) are deliberately NOT frozen: the daemon is authoritative for them
672
+ * LIVE (§5.2's context tier) and freezing would invert that, pinning a possibly-BEHIND
673
+ * relayed copy over fresher daemon rows; unfrozen they keep exactly the live tiering.
674
+ * 3. **Disconnect** the channel ({@link disconnectSource}): handlers detached, gate + buffer
675
+ * dropped. `nextMid`/`watermark`/processed-outcomes for the domain are KEPT FOREVER (§7.1:
676
+ * an assigned mid pins its domain; a later re-upgrade of the same doc continues the
677
+ * sequence — {@link connectSource} attaches a fresh gate and the lmid snapshot max-folds
678
+ * into the surviving watermark). Disconnecting BEFORE the daemon sub's first release is
679
+ * load-bearing: it makes {@link flushRetargetGc}'s deferred old-channel GC a no-op (gate
680
+ * gone ⇒ record deleted, nothing dropped) — running that GC would rewind the room slice's
681
+ * rows at daemon hydration, i.e. BEFORE the fence, surfacing a lagging follower's stale
682
+ * images (the exact regression §4.2 exists to prevent). The ghost's rows leave only
683
+ * through {@link dropGhost}'s `removeRoomSource`, value-equal under the fence.
684
+ * 4. **Ghost + first evaluation**: the record joins {@link ghosts} and is evaluated once
685
+ * immediately — `finalFlushSeq === 0` (a never-flushed room) with no room-domain pending
686
+ * drops on the spot, the single-daemon first-frame case.
687
+ *
688
+ * In-flight discipline (§7.5): entries with `mid !== null` on `sourceKey` stay PINNED (rule
689
+ * 2 — never re-route a sent mutation); their resolution arrives via the daemon-carried
690
+ * ledger+outcome folds (I-iii) and blocks the drop until then. Idempotent per sourceKey (a
691
+ * second labeled query sharing the room demotes into the existing ghost). */
692
+ demoteRoomSource(sourceKey: string, doc: string, finalFlushSeq: number): void;
693
+ /** Detach one connected room channel (Slice I-v step 3): the source's handlers are replaced
694
+ * with no-ops (the {@link OptimisticSource} handler seam is single-registration, so this IS
695
+ * the detach — a late frame from a dying socket can no longer touch any bookkeeping), its
696
+ * reserved lmid sub is unregistered, and the gate — buffer, per-source sync, cv watermark —
697
+ * is dropped from {@link gates}. The DOMAIN state deliberately survives forever:
698
+ * `nextMid[sourceKey]`, `watermark[sourceKey]`, and the processed-outcome set are untouched
699
+ * (§7.1 — an assigned mid pins its domain; a re-upgrade must continue, never restart, the mid
700
+ * sequence; {@link connectSource} then attaches a fresh gate whose lmid snapshot max-folds
701
+ * into the surviving watermark via {@link foldConfirm}). Closing the underlying transport is
702
+ * the caller's job. Idempotent (a missing gate is a no-op). */
703
+ disconnectSource(sourceKey: string): void;
704
+ /** Register the I-v stuck-downgrade sink — see {@link DowngradeStuckEvent}. One handler (a
705
+ * later registration replaces it, the {@link onScopeSessions} convention); client.ts maps it
706
+ * onto the loud anomaly surface. */
707
+ onDowngradeStuck(handler: (event: DowngradeStuckEvent) => void): void;
708
+ /** The I-v ghost-drop watcher (§4.2), run after every applied release ({@link applyRelease} —
709
+ * the seam where {@link roomWatermarks} has just folded and the confirm-drop has just run) and
710
+ * once at demote time. For each ghost: the fence must be satisfied
711
+ * (`roomWatermarks[doc] ≥ finalFlushSeq`; 0 is trivially satisfied) AND no SENT room-domain
712
+ * pending may remain (§7.5 — such entries resolve only through the daemon-carried
713
+ * outcome/ledger folds; an entry that never reached the room is undecidable, so the ghost
714
+ * HOLDS and the stuck event fires exactly once, naming the mids). Both satisfied ⇒
715
+ * {@link dropGhost}. */
716
+ private evaluateGhosts;
717
+ /** Drop one cleared ghost (§4.2 end state): detach every promoted table's room source (D2
718
+ * stay-merged — `removeRoomSource` reconciles each held pk to daemon-or-vanish, value-equal
719
+ * under the fence, so visually a no-op), release the {@link promotedTables} record for tables
720
+ * no OTHER room/ghost still holds, then run ONE daemon reconcile so entries whose writes had
721
+ * staged onto the ghost slice re-invoke and re-stage by provenance onto the daemon slice (the
722
+ * removal took their staged copies with the tree; provenance now answers daemon). The whole
723
+ * drop runs under one commit boundary so the removal's compensating deltas and the re-staged
724
+ * predictions notify as ONE step. After this, a FUTURE upgrade of the same doc promotes
725
+ * again from scratch — the round trip is pinned by the §8.5 lanes. */
726
+ private dropGhost;
727
+ /** Retain one minted SYSTEM subscription (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §4, Slice
728
+ * I-iii): a wire sub with NO store view and NO user-visible table. Registered through the same
729
+ * {@link RemoteSub} bookkeeping as any remote retain — so qid→channel ownership, the overflow
730
+ * re-subscribe, and refcounted release all work unchanged — but with an EMPTY `localQids` set
731
+ * (no hydration/resultType coupling) and a {@link systemQids} record telling the release path
732
+ * which system table this qid's frames carry (`spec.table`) and which scope/doc it was minted
733
+ * for (the fold's row filter). Its frames then buffer on the channel's gate exactly like
734
+ * {@link LMID_QID}'s and fold at RELEASE time in {@link foldSystemFrames} — riding the SAME
735
+ * buffered cv path as the data they co-committed with (fence coherence: an out-of-band
736
+ * shortcut would break I-ii's co-commit ordering guarantee).
737
+ *
738
+ * `channel` defaults to `"daemon"` — the system tables live in the DAEMON store (that is the
739
+ * point: outcome/ledger/watermark rows must be readable with no room socket alive, §7.1
740
+ * "load-bearing for §7.5"). Idempotence per (table, scope/doc) is the CALLER's job (client.ts
741
+ * keys its retains on exactly that); a duplicate retain of the SAME remote identity refcounts
742
+ * like any sub. */
743
+ retainSystemQuery(retainQid: QueryId, remote: RemoteQuery, spec: SystemStreamSpec, channel?: string): void;
744
+ /** Release a {@link retainSystemQuery} retain. Refcounted like any sub; the LAST release
745
+ * unregisters from the owning channel, sweeps its buffered frames, and drops the
746
+ * {@link systemQids} record. The folded lifecycle STATE (`roomWatermarks`/`scopeSessions`/
747
+ * processed outcomes) deliberately survives — the fence is monotone truth about the store, not
748
+ * about the subscription (a re-retained fence must not forget a cleared watermark). */
749
+ releaseSystemQuery(retainQid: QueryId): void;
230
750
  /** Raw CRUD has no optimistic story (§9 replaces it with named mutators). Register a
231
751
  * mutator — even a trivial one — and `invoke` it. */
232
752
  mutate(_mutations: Mutation[]): Promise<void>;
@@ -234,8 +754,26 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
234
754
  * straight to the local engine, OUTSIDE the optimistic pending stack — a local table is
235
755
  * untracked, so it never rebases, reverts, or waits on a confirmation. Rejects a synced/tracked
236
756
  * table (the local engine's `writeLocal` is the chokepoint). Reconcile is synchronous and
237
- * non-reentrant (A5), so a local write can never interleave with an open server cycle. */
757
+ * non-reentrant (A5), so a local write can never interleave with an open server cycle.
758
+ *
759
+ * Fires the {@link onLocalWrite} observer AFTER the engine commit but BEFORE subscriber
760
+ * delivery — i.e. for exactly the batches that passed the M2 guard and committed (the
761
+ * persistence layer's write-through tap, `207-LOCAL-TABLE-PERSISTENCE-DESIGN.md` §5.1). A
762
+ * subscriber throwing during delivery re-raises out of this call, but only after the tap has
763
+ * seen the batch: a committed write can never be invisible to the persistence plane. */
238
764
  writeLocal(mutations: Mutation[]): void;
765
+ /** The write-through tap for the local-persistence layer (207 §5.1): `observer` sees every
766
+ * {@link writeLocal} batch post-commit. One observer (the layer); a later registration
767
+ * replaces it. The observer must not throw — a persistence failure degrades durability, never
768
+ * the write path (P9); the layer catches internally. */
769
+ onLocalWrite(observer: (mutations: Mutation[]) => void): void;
770
+ /** Apply a REPLICATED local batch (a restore snapshot / a leader `commit` — 207 §5.1): delegates
771
+ * to the engine's `writeLocal`, so the M2 locality guard still fires (P8 — a corrupt record
772
+ * naming a synced table dies loudly here), but does NOT invoke the {@link onLocalWrite}
773
+ * observer — the echo guard is structural, so the persistence layer can never re-enter itself.
774
+ * `onCommitted` fires post-commit pre-delivery (same anchor as {@link writeLocal}'s tap): the
775
+ * layer updates its mirror there, so a subscriber throw can never desync mirror from engine. */
776
+ applyLocalReplica(mutations: Mutation[], onCommitted?: () => void): void;
239
777
  onEvent(handler: (qid: QueryId, ev: ChangeEvent) => void): void;
240
778
  onCommitBoundary(handler: (phase: "begin" | "end") => void): void;
241
779
  /** Bracket a multi-step optimistic apply as ONE notification commit (cross-view-atomic
@@ -254,10 +792,51 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
254
792
  * state (read-your-writes), the SAME body the API server drives asynchronously. `ctx.user` is
255
793
  * the acting principal (re-read per invoke, stable across a rebase re-invoke). */
256
794
  private runMutator;
795
+ /** Deal the next wire mid from `domain`'s ledger (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md
796
+ * §7.1) and advance that counter. A domain absent from the map starts at 1. Per-domain, so a
797
+ * client writing through room + daemon concurrently keeps two gapless, non-aliasing sequences.
798
+ * The client-global `seq` is stamped in the same breath — the ONE cross-domain total order
799
+ * (confirmation order is per-domain; replay order is client-global). Bundled here so no call
800
+ * site can deal a mid without its seq. ONE caller discards the seq deliberately: the H-v deopt
801
+ * flip ({@link handleMutationOutcome}) keeps the entry's ORIGINAL seq — its replay position —
802
+ * and takes only the fresh mid (the dealSeq bump is harmless: seq consumers order, never
803
+ * count). */
804
+ private dealMid;
805
+ /** The route decision for one invocation (invoke, and again at fold flush): the explicit
806
+ * `domainPolicy` is the OVERRIDE — a string pins that domain verbatim, no proof runs;
807
+ * `undefined` (or no policy configured) derives per §3. */
808
+ private resolveDomain;
809
+ /** §9.3: does this fold's write-set route into a room (vs the daemon)? Used ONLY to pick the
810
+ * {@link FoldOptions.roomDebounceMs} cadence at a fold window's start. It probes
811
+ * {@link resolveDomain} but RESTORES the Q6 routing diagnostics afterward — the authoritative
812
+ * route and its single counter bump belong to the flush ({@link flushFold}), not to this
813
+ * interval hint. A fold's reads are provably empty (the read-trap arms on that path), so the
814
+ * write-set alone decides. */
815
+ private routesToRoom;
816
+ /** Count one derivation failure ({@link RoutingInspect}). Returns `false` so the per-candidate
817
+ * proof's call sites read `return this.failDerivation(...)`. */
818
+ private failDerivation;
819
+ /** The §3 derivation. Candidates are the connected room gates with at least one promoted table
820
+ * (a promoted table with no gate cannot confirm; a gate with no promoted table holds no data to
821
+ * prove against). Zero candidates ⇒ `"daemon"` (the single-domain fast path). The proof runs
822
+ * per candidate and EXACTLY ONE must survive — two rooms both proving routes slow (principled
823
+ * disambiguation is a §9.2 multi-room NON-goal, deferred past Slice J; slow is always sound). */
824
+ private deriveDomain;
825
+ /** One candidate room's §3 proof over the captured write-set + read-log. Every write must pass
826
+ * ALL the write rules; every read must pass ONE of the read rules. First failure wins (and is
827
+ * counted); order is deterministic (write-set map order, then read-log order). */
828
+ private provesRoom;
257
829
  /** Run the named client mutator optimistically: the prediction applies to the live
258
830
  * engine now (affected views update synchronously), `(mid, name, args)` joins the
259
831
  * pending stack, and the envelope ships upstream. Returns the assigned `mid`. */
260
832
  invoke(name: string, args: unknown): number;
833
+ /** {@link invoke} with an optional PINNED confirming domain (H-v): the deopt handshake's
834
+ * already-retired arm re-invokes the frame's echoed `(name, args)` as a FRESH invocation pinned
835
+ * to `"daemon"` — an honest re-prediction on the current base, never derived (`pin` bypasses
836
+ * {@link resolveDomain} entirely, so the router never runs and no Q6 counter moves). Every
837
+ * other step is `invoke` verbatim: prediction now, capture, drainOverlapping, mid dealt from
838
+ * the pinned domain's ledger, envelope on its channel. */
839
+ private invokeWith;
261
840
  /** Run a FOLDED invoke (FOLDED-MUTATIONS-DESIGN §8): apply the prediction to the live engine now
262
841
  * (like `invoke`), but collapse a run of same-key invokes into ONE pending entry whose `args`
263
842
  * are overwritten in place, debounce the server write, and ship only the last value. The `mid`
@@ -272,6 +851,66 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
272
851
  * by construction), stamp the entry, ship the envelope with the LATEST args, resolve the handle.
273
852
  * The entry stays on `pendingMutations` (now with a real mid) until the lmid release confirms it. */
274
853
  private flushFold;
854
+ /** The transport a `domain`-confirmed mutation ships on (§7.5 sent-pins-domain: only the
855
+ * domain's own authority can confirm it, so its channel is the only correct transport). A
856
+ * domain with NO connected gate ships on the daemon channel — the gate-less configurations
857
+ * (`__testRelease`-driven tests) and today's entire live path resolve `"daemon"` anyway. */
858
+ private channelFor;
859
+ /** The gate a channel-keyed retain registers through (G-iii registration-time routing). The
860
+ * channel MUST already be connected (`connectSource`; the daemon is constructor-attached) —
861
+ * loud by design: a typo'd or not-yet-connected sourceKey must throw at retain time, never
862
+ * silently register on the daemon and split the query's frames across channels. */
863
+ private requireGate;
864
+ /** The channel that owns `sourceQid` — {@link RemoteSub.channel}, the ONE source of truth for
865
+ * qid routing (G-iii). `undefined` when no sub owns the qid (a harness-delivered raw feed, or
866
+ * a just-released sub): such frames buffer on whatever gate they arrive at. */
867
+ private channelOf;
868
+ /** Record `(domain, mid)` as processed; `false` if it already was (a duplicate frame —
869
+ * ignore it). FIFO-capped per domain ({@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}). */
870
+ private markOutcomeProcessed;
871
+ /** One `mutationOutcome` frame from `domain`'s channel (H-v — the §3.3 handshake's client
872
+ * half). The frame arrives OUT-OF-BAND (see {@link attachGate}); the state machine:
873
+ *
874
+ * 1. `mid` never issued on `domain` ⇒ ignore (a confused/foreign frame must not invent work).
875
+ * 2. `(domain, mid)` already processed ⇒ ignore — idempotence under duplicate frames (the
876
+ * original + a re-send's re-answer; a deopt for a mid whose entry ALREADY FLIPPED also
877
+ * lands here harmlessly on its second frame).
878
+ * 3. `kind:"rejected"` ⇒ FINAL. Surface the reason through {@link rejectedHandler} (room-plane
879
+ * parity with the HTTP queue's callback) and STOP — the drop + snap-back is the EXISTING
880
+ * failed-mutation machinery: the room burnt the mid, its lmid release retires the entry
881
+ * per-domain and the reconcile rewinds the prediction, exactly the daemon path's
882
+ * processed-as-no-op rejection. No new drop path.
883
+ * 4. `kind:"deopt"`, entry found (pending `(domain, mid)`) ⇒ FLIP IN PLACE: `domain` becomes
884
+ * `"daemon"`, a fresh daemon mid is dealt and the envelope ships NOW on the daemon channel
885
+ * ("deal-and-send-now" — the conforming §3.3 re-enqueue: there is no flush machinery for
886
+ * non-fold entries, so the design's "mid: null until the daemon flush" is satisfied
887
+ * momentarily inside this call). THE ENTRY'S `seq` IS KEPT — settled (§5.3, commit
888
+ * 68141096): `seq` is the client-global REPLAY order; re-sequencing would move the entry's
889
+ * overlay position and change read-dependent SIBLINGS' replay base. Everything else stays
890
+ * (writes/reads/touched/touchedSources/writeSources — union-never-shrink), the prediction
891
+ * stays applied (the entry never leaves `pendingMutations`, so no rewind fires), and the
892
+ * router does NOT re-run nor does `drainOverlapping` (§3.3 re-enqueues, never re-derives;
893
+ * any open overlapping fold was invoked later and flushes later with a larger mid).
894
+ * 5. `kind:"deopt"`, entry NOT found ⇒ the burnt-mid confirm won the race, or the frame is a
895
+ * replay re-answer for an entry a previous session retired (the replay gotcha): re-invoke
896
+ * the frame's echoed `name`/`args` as a FRESH invocation PINNED to `"daemon"` — an honest
897
+ * re-prediction on the current base, never a derived route ({@link invokeWith}). A frame
898
+ * without `name` (not self-contained) has nothing to re-invoke and is dropped; a re-invoke
899
+ * that THROWS (the base moved from under it) is surfaced through {@link rejectedHandler} —
900
+ * the mutation is dead with no stream left to confirm it.
901
+ *
902
+ * A `"deopt"` bump joins the Q6 routing counters either way (`routing.reasons.deopt`) —
903
+ * derived-and-deopted routes are visible beside derived successes. */
904
+ private handleMutationOutcome;
905
+ /** §7.5 rule 3 (H-v): re-send `domain`'s unconfirmed pending envelopes with their ORIGINAL
906
+ * mids, in mid order, on the domain's own channel. Folds with `mid === null` are excluded —
907
+ * nothing was ever sent for them (the flush deals their mid). Envelopes are reconstructed from
908
+ * the pending entries exactly as `invoke` shipped them (`clientID`/`mid`/`name`/`args` —
909
+ * entries carry everything the wire needs). Idempotent under the domain's ledger: an APPLIED
910
+ * mid dedups silently and its lmid coverage retires the entry; a NON-APPLIED mid is re-answered
911
+ * from the shell's recorded-outcome map into {@link handleMutationOutcome}. Confirmed entries
912
+ * are already gone from `pendingMutations`, so no filter against the watermark is needed. */
913
+ private resendPending;
275
914
  /** Drain every outstanding fold immediately (FOLDED-MUTATIONS-DESIGN §3): the explicit
276
915
  * `app.flushFolds()` and the `beforeunload`/`close` hook. Creation (insertion) order. */
277
916
  flushFolds(): void;
@@ -304,17 +943,213 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
304
943
  * §4.1). Built fresh per call from state the backend already holds — no new instrumentation, no
305
944
  * mutation. Only ever called by `@rindle/devtools` (imported in dev). */
306
945
  __inspect(): OptimisticInspect;
946
+ /** Test-only per-domain ledger snapshot (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.1/§8.5).
947
+ * Kept separate from {@link __inspect} so the devtools `OptimisticInspect` mirror stays byte-for-
948
+ * byte identical (daemon-scalar-only). Exposes the per-domain `nextMid`/`watermark` maps plus each
949
+ * pending entry's confirming domain — the axes the §8.5 ledger-isolation assertion checks. */
950
+ __inspectDomains(): {
951
+ nextMid: Record<string, number>;
952
+ watermark: Record<string, number>;
953
+ /** Per connected CHANNEL (§5.1): its release watermark + buffered-frame depth — the axis the
954
+ * gate-isolation assertions read (one source's laggy cvMin must never move the other's) —
955
+ * plus the §301 upstream-absorption advert last recorded from its progress frames (a ROOM
956
+ * gate with a new shell; absent otherwise). */
957
+ gates: Record<string, {
958
+ appliedCv: number;
959
+ bufferedFrames: number;
960
+ upstreamCv?: number;
961
+ upstreamBoot?: string;
962
+ }>;
963
+ /** The §3 router's counters (H-iii) — Q6's measurement hook: how often derivation succeeded
964
+ * (per derived room) and why it fell to the daemon (per reason). See {@link RoutingInspect}
965
+ * for the counting discipline (per-candidate failures; pins bump nothing). */
966
+ routing: RoutingInspect;
967
+ /** The §4 lifecycle plane's folded state (Slice I-iii introspection): the per-doc §4.2 fence
968
+ * value (`roomWatermarks`, I-v's ghost-drop input), the per-scope §4.1 occupancy map
969
+ * (`scopeSessions`: scope → client_id → expires_at, I-iv's doorbell input), and the live
970
+ * I-v ghosts (demoted room sources still awaiting their fence). */
971
+ lifecycle: {
972
+ roomWatermarks: Record<string, number>;
973
+ scopeSessions: Record<string, Record<string, number>>;
974
+ ghosts: Record<string, {
975
+ doc: string;
976
+ finalFlushSeq: number;
977
+ tables: string[];
978
+ }>;
979
+ };
980
+ /** The live §301 pin registry (`301-ECHO-FENCE-DESIGN.md` §2.5 — the devtools/Q6 surface):
981
+ * every parked hold-back's fence inputs plus its tripwire state, keyed
982
+ * `${table}\0${sourceKey}\0${pkKey}`. Pruned lazily, so an entry here may briefly outlive
983
+ * its engine pin (never the reverse). */
984
+ pins: Record<string, {
985
+ table: string;
986
+ sourceKey: string;
987
+ domain: string;
988
+ mid: number;
989
+ daemonBoot?: string;
990
+ daemonCv?: number;
991
+ tripwired: boolean;
992
+ }>;
993
+ /** The §301 direction-A fence map: per room domain, the highest DAEMON-CARRIED ledger lmid. */
994
+ daemonCarriedLmid: Record<string, number>;
995
+ pending: {
996
+ mid: number | null;
997
+ seq: number | null;
998
+ name: string;
999
+ domain: string;
1000
+ touchedSources: string[];
1001
+ writeSources: Record<string, string>;
1002
+ }[];
1003
+ };
307
1004
  /** Recompute the pending axis for every query and fire `onPending` on transitions only. Called
308
1005
  * from the two points that move the pending set: invoke/invokeFolded (add) and the confirm-drop
309
1006
  * (remove) — exactly where `:359`/`:468` used to flip ResultType (§7.3). */
310
1007
  private refreshPending;
311
- private onNormalized;
1008
+ private onFrame;
312
1009
  private emitServerDelta;
313
1010
  private localQidsForSource;
314
- private onProgress;
315
- /** Fold the lmid system query's released ops (lmid-as-data): the one row's
316
- * `last_mutation_id` cell is this client's confirmed high-water mid. */
1011
+ /** One gate's release (§5.1 release gate): compute the coherent delta from THIS gate's cv-buffer,
1012
+ * then apply it against the gate's source/domain. Split into {@link computeRelease} (buffer →
1013
+ * delta, lmid watermark) and {@link applyRelease} (per-source confirm-drop + reconcile) —
1014
+ * N independent gates all feed the ONE apply half; {@link __testRelease} drives it directly. */
1015
+ private onGateProgress;
1016
+ /** Compute one coherent release from ONE gate's cv-buffer (§5.1) — gate-scoped: its buffer, its
1017
+ * cvMin timeline. Take every buffered frame at `cv ≤ cvMin`, in (cv, arrival) order, and fold
1018
+ * it: the lmid system-query frame advances `watermark[gate.key]` (via {@link foldLmidOps} — the
1019
+ * daemon stream folds "daemon", a room stream folds its own domain); data frames fold through
1020
+ * this SOURCE's cross-query refcount into ONE net base delta — the §1.3 `D`. Returns that delta
1021
+ * plus the set of local views this release JUST hydrated (so their reconcile batch phases as a
1022
+ * `snapshot`). Mutates the gate's buffer/`appliedCv`, hydration, and the gate's domain
1023
+ * watermark; the pending set and the reconcile are {@link applyRelease}'s job. */
1024
+ private computeRelease;
1025
+ /** Apply one released delta against `sourceKey`'s domain (§7.2 per-domain confirm-drop + the §1.3
1026
+ * reconcile cycle). `watermarkUpdate`, when given, advances `watermark[sourceKey]` first — the
1027
+ * hook a per-source lmid confirm rides on (the daemon path folds its watermark in
1028
+ * {@link computeRelease} and passes `undefined`). Then: drop every pending entry its OWN domain's
1029
+ * watermark now covers (a room confirm can never retire a daemon entry, and vice-versa — the §7.1
1030
+ * ledger-collision fix), and run the reconcile cycle against `sourceKey` when the base delta or the
1031
+ * pending set changed. `newlyHydrated` stamps the initial-hydration batch as a catch-up. */
1032
+ private applyRelease;
1033
+ /** Test-only per-source release seam (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §7.2/§8.5): drive
1034
+ * {@link applyRelease} for `sourceKey` directly — an explicit `watermarkUpdate` (a simulated lmid
1035
+ * confirm for that domain) and `deltas` (a coherent base delta), with no real gate. Lets a harness
1036
+ * exercise a room-domain confirm before the real second lmid stream / per-source gate is wired
1037
+ * (E-iii-b/c). The `__`-prefix marks it a test hook, alongside {@link __inspect}. */
1038
+ __testRelease(sourceKey: string, deltas: Mutation[], watermarkUpdate?: number): void;
1039
+ /** Register (or overwrite — a later cross-slice confirm re-pinning the same slice-pk matches
1040
+ * the engine's `held_back.insert`) one {@link EchoFencePin}, in the same breath as the
1041
+ * engine park (§2.1). `p` is the confirmed entry whose write staged onto `sourceKey`; its
1042
+ * `mid` is non-null by the caller's confirm filter. */
1043
+ private registerPin;
1044
+ /** The §2.3 drop pass, run at the tail of every applied release: prune registry entries whose
1045
+ * engine pin is already gone (the rewind's state-match fallback or a whole-source removal beat
1046
+ * the fence — benign), drop every pin whose fence cleared (one engine drop each, delivered on
1047
+ * the ordinary event stream, bracketed as ONE notification commit), and tripwire the rest. */
1048
+ private dropEchoFencePins;
1049
+ /** Has `pin`'s delivery fence provably closed its confirm→echo window? (§2.3/§2.4.) */
1050
+ private pinFenceCleared;
1051
+ /** The §2.5 stuck-pin tripwire, in the scopesHash spirit: log ONCE per pin when its slice's
1052
+ * baseline row has CHANGED VALUE since park while the pin still holds — the suspicious state
1053
+ * that precedes every forever-pin (a fence-less pairing, a fence bug). Never drops anything. */
1054
+ private maybeTripwirePin;
1055
+ /** Record one observed daemon boot id (§2.4): first observation of an id assigns the next
1056
+ * ordinal (the client's own total order over opaque boot ids); every call refreshes the
1057
+ * current-boot stamp for direction-B parks. */
1058
+ private observeDaemonBoot;
1059
+ /** Promote `table` to a MERGED multi-source engine with room `sourceKey`'s per-row writable
1060
+ * scope (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §5.2) — THE one promotion seam. The engine
1061
+ * attach and the {@link promotedTables} record move in the same breath: the §7.3 hold-back
1062
+ * trigger in {@link applyRelease} parks ONLY on promoted tables, so a promotion that bypassed
1063
+ * the record would silently disable the echo hold-back for that table (and a record without the
1064
+ * engine attach would park onto a slice that doesn't exist). Recorded AFTER the engine accepts —
1065
+ * a rejected descriptor/table must not leave a phantom promotion. Slice G-v's client drives this
1066
+ * from the lease's `realtime.tables` (`RoomTableSpec` → {@link WritableDescriptor}); idempotence
1067
+ * per `(sourceKey, table)` is the CALLER's job (the engine refuses a duplicate room).
1068
+ *
1069
+ * `spec` (H-iii) is the per-table ROUTING spec riding the same lease table block — recorded
1070
+ * into {@link roomRouting} (THE routing table the §3 router reads) in the same breath, with
1071
+ * `writable` derived from the descriptor's kind (`none` = a context table the room may not
1072
+ * write). Omitted (the E-iii-b harness alias below) ⇒ an empty spec: no join keys to guard, no
1073
+ * `footprintWhere` (that room's reads then fail closed to the daemon unless self/room-served). */
1074
+ promoteRoomTable(table: string, sourceKey: string, writable: WritableDescriptor, spec?: RoomTableRoutingSpec): void;
1075
+ /** The recorded routing specs for `sourceKey`'s promoted tables (table → its
1076
+ * {@link RoomTableRouting}) — read-only: the client's `__realtimeInspect`/idempotence
1077
+ * bookkeeping reads THIS record instead of keeping its own shadow copy (one source of truth).
1078
+ * Empty map when the room has promoted nothing. */
1079
+ roomTablesFor(sourceKey: string): ReadonlyMap<string, RoomTableRouting>;
1080
+ /** Test-named alias of {@link promoteRoomTable} (E-iii-b scaffolding — the multi-domain oracle
1081
+ * and per-source-gate suites drive it). Pure delegation: ONE body, one `promotedTables` record. */
1082
+ __addRoomSource(table: string, roomKey: string, writable: WritableDescriptor, spec?: RoomTableRoutingSpec): void;
1083
+ /** Fold `domain`'s lmid system query's released ops (lmid-as-data): the one row's
1084
+ * `last_mutation_id` cell is this client's confirmed high-water mid in that domain — it advances
1085
+ * `watermark[domain]` and, on a fresh session ahead of our issued mids, `nextMid[domain]`. The
1086
+ * daemon stream folds `"daemon"`; a room stream folds its own `"room:doc:X"`; the daemon-carried
1087
+ * §7.1 ledger rows fold through the same {@link foldConfirm} core (Slice I-iii). */
317
1088
  private foldLmidOps;
1089
+ /** THE one confirm fold (§7.1/§7.2): advance `watermark[domain]` to `lmid` (monotone max) and,
1090
+ * on a fresh session ahead of our issued mids, adopt `nextMid[domain]`. Shared verbatim by the
1091
+ * per-channel lmid system query ({@link foldLmidOps}) and the daemon-carried room-ledger rows
1092
+ * ({@link foldSystemFrames} — one core so the two paths cannot drift). */
1093
+ private foldConfirm;
1094
+ /** Fold one release's SYSTEM frames in a FIXED category order — the order is STRUCTURAL (one
1095
+ * function, categories in sequence), because it is the client half of THE NAMED INVARIANT
1096
+ * (§3.3's shipped note; documented above {@link handleMutationOutcome}): **never retire a
1097
+ * room-domain entry off a daemon-carried lmid without outcome resolution.**
1098
+ *
1099
+ * 1. **outcome rows** (`_rindle_room_mutation_outcomes`) — each row for OUR clientID is
1100
+ * synthesized into a {@link MutationOutcomeFrame} and routed through
1101
+ * {@link handleMutationOutcome}, the SAME H-v state machine the room socket's frames use
1102
+ * (one verdict path: frames and rows cannot drift). A deopt flips its pending entry to
1103
+ * the daemon IN PLACE (keep-seq, deal-and-send-now); a rejection surfaces + stays for the
1104
+ * ordinary burnt-mid retire; a duplicate (frame already seen, or the row re-delivered) is
1105
+ * absorbed by the processed set — which doubles as the resolved-verdict memory across
1106
+ * releases (per-domain FIFO, {@link MAX_PROCESSED_OUTCOMES_PER_DOMAIN}, mirroring the
1107
+ * shell's recorded-outcome cap).
1108
+ * 2. **room-ledger rows** (`_rindle_room_client_mutations`) — the FIRST daemon-carried
1109
+ * room-lmid path: OUR row's `last_mutation_id` folds into `watermark[room:<doc>]` via
1110
+ * {@link foldConfirm}. Because step 1 ALREADY resolved every non-applied verdict this
1111
+ * release carries (and earlier releases' verdicts were resolved at their own release),
1112
+ * the confirm-drop that follows in {@link applyRelease} retires only entries whose
1113
+ * outcome is resolution-by-absence — which I-ii's co-commit atomicity defines as APPLIED
1114
+ * (a room flush co-commits the ledger row and every non-applied mid's outcome row in ONE
1115
+ * daemon transaction, so a covering lmid without a row IS the applied verdict).
1116
+ * Processing this category before step 1 is the violation, in two proven directions
1117
+ * (each run break→fail→revert against `test/system_streams.test.ts`): (a) the ledger's
1118
+ * fresh-session `nextMid` ADOPTION must not run before historical outcome rows are
1119
+ * judged — adopted-first, a previous session's retained deopt row passes the
1120
+ * "never-issued" guard and spuriously re-invokes a mutation that session already handled
1121
+ * (a double-apply); (b) the RETIRE must not precede resolution — it does not BECAUSE the
1122
+ * confirm-drop runs in {@link applyRelease}, strictly after this whole function. That
1123
+ * deferral is load-bearing: an "optimization" retiring inline with the watermark fold
1124
+ * retires a deopted entry as a silent success (the exact lost-write H-v exists to
1125
+ * prevent) and mis-attributes a rejected row's reason.
1126
+ * 3. **watermark rows** (`_rindle_room_watermark`) — the §4.2 fence value, max-folded per
1127
+ * doc ({@link roomWatermarks}); I-v's ghost-drop consumer, no reaction here.
1128
+ * 4. **scope-session rows** (`_rindle_scope_sessions`) — the §4.1 occupancy map
1129
+ * ({@link scopeSessions}); I-iv's doorbell consumer, no reaction here.
1130
+ *
1131
+ * Ordinary data ops fold AFTER all of these (the caller's main loop) — outcome/ledger state
1132
+ * must be in place before {@link applyRelease}'s confirm-drop + reconcile consume the release.
1133
+ * Every row is filtered against the retain's {@link SystemStreamSpec} scope/doc AND (for the
1134
+ * client-keyed tables) our own `clientID` — defense in depth: the server predicate may have
1135
+ * been minted doc-only (no `clientId` at lease time), so other clients' rows are expected and
1136
+ * must be ignored, and a row for a doc this retain was not minted for is never folded.
1137
+ *
1138
+ * Returns the scopes category 4 touched (snapshot or ops) — the I-iv doorbell events' input;
1139
+ * `null` when none (every non-lifecycle release). The events themselves fire from
1140
+ * `onGateProgress` AFTER the release applies, never from inside the fold. */
1141
+ private foldSystemFrames;
1142
+ /** The I-iv occupancy count — THE one rule (§4.1/D7): unexpired (`expires_at >` the fold
1143
+ * clock's now) sessions under `scope` from OTHER clientIDs. Shared by the doorbell events
1144
+ * ({@link onGateProgress}) and the client's registration-time check (a doorbell that folded
1145
+ * BEFORE a candidate registered must still be able to trigger it) so the two can never
1146
+ * disagree. Own-clientID rows never count — a solo client cannot ring its own bell — and
1147
+ * expiry is judged on the injectable {@link FoldClock} (deterministic in a virtual-clock
1148
+ * harness, the folded-oracle discipline). */
1149
+ otherScopeSessions(scope: string): number;
1150
+ /** Register the I-iv doorbell event sink — see {@link ScopeSessionsEvent}. One handler (a later
1151
+ * registration replaces it, the {@link onLocalWrite} convention); client.ts is the consumer. */
1152
+ onScopeSessions(handler: (event: ScopeSessionsEvent) => void): void;
318
1153
  /** One §1.3 reconcile cycle: rewind the optimistic layer and fold the coherent SERVER
319
1154
  * delta into BOTH head AND the `sync` baseline (`serverBatchBegin`), re-invoke every
320
1155
  * still-pending mutator to re-stage the optimistic layer (the rewind un-applied it), then
@@ -322,18 +1157,20 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
322
1157
  * boundary — `onProgress` releases and `unregisterQuery`'s GC both go through here so head
323
1158
  * and sync never diverge (the §1.2 invariant; CRIT#2). */
324
1159
  private runReconcileCycle;
325
- /** The daemon restarted (a new boot id): it lost all materialization + `cv` state and its `cv`
326
- * sequence reset, so previously-released `cv`s no longer bound the new stream. The source has
327
- * already re-subscribed every query (reconnect → resync); drop the buffer and the `cv`
328
- * watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
329
- * (`onNormalized`/`onProgress` gate on `appliedCv`). Pending optimistic mutations stay put
330
- * they re-apply on the next reconcile, and the lmid system query's fresh snapshot restores the
331
- * confirmation watermark. */
332
- private resetForRestart;
333
- /** The §8.5 escape: the buffer outgrew its cap (a pinned `cvMin` under churn). Drop
334
- * everything buffered and re-register every query on the source the fresh
335
- * snapshots arrive as ordinary frames and the next release re-hydrates via the
336
- * footprint diff (the §5.3 path); still-pending optimism re-applies in that cycle. */
1160
+ /** ONE channel's authority restarted (a new boot id): it lost all materialization + `cv` state
1161
+ * and its `cv` sequence reset, so previously-released `cv`s no longer bound the new stream. The
1162
+ * source has already re-subscribed every query (reconnect → resync); drop THIS gate's buffer
1163
+ * and `cv` watermark so the fresh, low-`cv` snapshots are RELEASED instead of dropped as stale
1164
+ * (`onFrame`/`computeRelease` gate on `appliedCv`). The OTHER gates are untouched an
1165
+ * authority restart is per-channel (§5.1). Pending optimistic mutations stay put they
1166
+ * re-apply on the next reconcile, and the channel's lmid system query's fresh snapshot restores
1167
+ * its domain's confirmation watermark. */
1168
+ private resetGate;
1169
+ /** The §8.5 escape: ONE gate's buffer outgrew its cap (a pinned `cvMin` under churn on that
1170
+ * channel). Drop everything it buffered and re-register every query on that source — the fresh
1171
+ * snapshots arrive as ordinary frames and the next release re-hydrates via the footprint diff
1172
+ * (the §5.3 path); still-pending optimism re-applies in that cycle. The other gates' buffers
1173
+ * and subscriptions are untouched. */
337
1174
  private overflow;
338
1175
  private setResultType;
339
1176
  /** Recompute a query's server-channel state from hydration alone (§7): a pending mutation no
@@ -343,8 +1180,26 @@ export declare class OptimisticBackend<S extends ColsMap> implements Backend {
343
1180
  * lift those views out of `unknown` (loading). Idempotent — a re-hydrate snapshot re-marks
344
1181
  * harmlessly; a source qid with no sub (the lmid system query) is a no-op. */
345
1182
  private markSubHydrated;
1183
+ /** `channel` (G-iii registration-time routing): the gate the sub registers on — the qid's
1184
+ * ownership is fixed HERE, at retain time (no lazy claim; `onFrame` only asserts it). Default
1185
+ * `"daemon"`, so every channel-less caller is byte-identical to before. */
346
1186
  private retainRemote;
347
1187
  private releaseRemote;
348
1188
  private addServerDependencyTables;
349
1189
  }
1190
+ /** Whether `cond` is decidable from the KEY ALONE: every column it references — collected from
1191
+ * BOTH operand positions (under-counting could over-claim decidability; over-counting only fails
1192
+ * closed — the H-iv-a discipline) — is a pk column. A `correlatedSubquery` is never key-local. */
1193
+ export declare function keyDecidable(cond: Condition, pkCols: ReadonlySet<string>): boolean;
1194
+ /** Evaluate a key-decidable `footprintWhere` on a read's pk cells — H-iii's ONE client-side
1195
+ * non-engine evaluator, DELIBERATELY MINIMAL (the TS evaluation caveat on the router block
1196
+ * comment): `simple` `=`/`!=` between a pk COLUMN and a same-primitive-type NON-NULL literal,
1197
+ * composed under and/or. The empty AND — the compiler's vacuous-true emission for an exact
1198
+ * unconstrained footprint root — evaluates `true` (load-bearing: whole-table footprints keep
1199
+ * provable reads); the empty OR is vacuous-false. Returns `undefined` = NOT EVALUABLE for
1200
+ * anything else (other ops, null on either side, cross-type comparisons, column-vs-column,
1201
+ * literal-vs-literal, non-primitive cells, correlated subqueries) — the caller fails to the
1202
+ * daemon. A non-evaluable node anywhere poisons the whole tree (no short-circuit past it):
1203
+ * partial evaluation could otherwise claim a verdict the engine's semantics might contradict. */
1204
+ export declare function evalFootprintOnPk(cond: Condition, pkCells: ReadonlyMap<string, WireValue>): boolean | undefined;
350
1205
  //# sourceMappingURL=backend.d.ts.map