@rindle/api-server 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/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
1
+ import { driveMutationAsync, insertCell, insertPlan, isGeneratorMutator, isoTx, toCell } from "@rindle/client";
2
2
  import type {
3
3
  Ast,
4
4
  ColType,
@@ -21,6 +21,8 @@ import type { Catalog, ColumnType as QueryColumnType, TableSchema } from "@rindl
21
21
  import type {
22
22
  ClaimRoomEpochInput,
23
23
  ClaimRoomEpochOutput,
24
+ CoverQueryInput,
25
+ CoverQueryOutput,
24
26
  DematerializeInput,
25
27
  DematerializeOutput,
26
28
  MaterializationPolicy,
@@ -51,12 +53,50 @@ import type {
51
53
  WireValue,
52
54
  } from "@rindle/daemon-client";
53
55
 
56
+ import {
57
+ assertLabeledProfilesExist,
58
+ assertUnwindowedFootprint,
59
+ attachRealtimeLabel,
60
+ compileRoomProfiles,
61
+ compileRoomScopeSpecs,
62
+ compileRoomTableSpecs,
63
+ mintRoomDoc,
64
+ queryRealtimeLabel,
65
+ queryResultToAst,
66
+ splitRoomDoc,
67
+ } from "./rooms.ts";
68
+ import type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
69
+ // The room lease token (RINDLE-REALTIME §10.1): minted here, verified by the room SHELL against
70
+ // its `downstream.tokenKeys` ring — the `/token` subpath is pure WebCrypto (no wasm, no shell).
71
+ // Loaded LAZILY at the first mint: `@rindle/room` is a dev-only workspace dep (it is not published
72
+ // to npm yet — infra/tests/publish-sync.mjs would flag a runtime dep on it), and the mint only
73
+ // runs when `realtime.roomTokenKey` is configured. In-repo (workspace linking) the import always
74
+ // resolves; an npm consumer without the package gets the serve decision's ordinary fail-open —
75
+ // daemon-served leases plus a one-time warning naming the missing module.
76
+ import type { mintRoomToken as MintRoomToken, scopeSpecsHash as ScopeSpecsHash } from "@rindle/room/token";
77
+ let roomTokenModule: { mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash } | undefined;
78
+ async function loadRoomTokenModule(): Promise<{ mintRoomToken: typeof MintRoomToken; scopeSpecsHash: typeof ScopeSpecsHash }> {
79
+ if (roomTokenModule === undefined) {
80
+ const m = await import("@rindle/room/token");
81
+ roomTokenModule = { mintRoomToken: m.mintRoomToken, scopeSpecsHash: m.scopeSpecsHash };
82
+ }
83
+ return roomTokenModule;
84
+ }
85
+
54
86
  // Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
55
87
  // co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
56
88
  // keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
57
89
  export { isoTx, shared } from "@rindle/client";
58
90
  export type { ArgSchema, IsoTx, MutationGen, MutatorCtx, SharedMutator, SharedMutatorWithArgs } from "@rindle/client";
59
91
 
92
+ // The room-profile declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a). The
93
+ // compiled-profile shapes stay internal to `./rooms.ts` — G-iv-b consumes them in-package.
94
+ // `RoomTableSpec` (G-iv-b) is public: it rides the lease wire (`QueryLeaseResponse.realtime`).
95
+ // `RoomScopeSpec` (H-iv-b) is public: it rides the boot wire (`RoomBootResponse.scopes`).
96
+ export { queryRealtimeLabel, queryResultToAst } from "./rooms.ts";
97
+ export type { RoomProfile, RoomScopeSpec, RoomTableSpec } from "./rooms.ts";
98
+ export type { RealtimeQueryLabel } from "@rindle/client";
99
+
60
100
  export const DEFAULT_RINDLE_API_ROUTES = {
61
101
  query: "/api/rindle/query",
62
102
  read: "/api/rindle/read",
@@ -149,6 +189,103 @@ export type ApiMutator<User, Args> = (
149
189
  args: Args,
150
190
  ctx: MutationContext<User>,
151
191
  ) => MaybePromise<ApiMutatorResult>;
192
+
193
+ // --------------------------------------------------------------------------- scoped (outside-tx) mutators
194
+ //
195
+ // The tx-form {@link ApiMutator} above runs ENTIRELY inside the transaction. A SCOPED mutator
196
+ // (WORK-OUTSIDE-TX) instead controls the boundary itself: it receives a {@link MutationScope}, runs
197
+ // server-only code BEFORE opening the one atomic transaction (`scope.transact`), and MAY run code
198
+ // AFTER it commits. The outside-tx code is server-only by nature (the client's optimistic prediction
199
+ // can't call Stripe), so it lives HERE, never in the isomorphic body — the shared generator stays
200
+ // pure and identical on both tiers; server-computed values flow into it through `ctx`, exactly like
201
+ // `ctx.user` (undefined/predicted on the client, authoritative here).
202
+
203
+ /** Thrown by {@link MutationScope.transact} when the transacted body BUSINESS-rejects: the data
204
+ * rolled back and `lmid` advanced alone (§2.4). Catch it to COMPENSATE an outside-tx side effect
205
+ * (refund the charge), then rethrow or return — the mutation's protocol outcome is already sealed
206
+ * as rejected, so a post-reject throw can't change it. A DB/infra failure is NOT this — it
207
+ * propagates as the raw driver error (the client retries; `lmid` did not advance). */
208
+ export class MutationRejected extends Error {
209
+ readonly reason: string;
210
+ constructor(reason: string) {
211
+ super(reason);
212
+ this.name = "MutationRejected";
213
+ this.reason = reason;
214
+ }
215
+ }
216
+
217
+ /** The per-mutation server handle a {@link ScopedMutator} runs against. Code before {@link transact}
218
+ * runs OUTSIDE the transaction; code after a clean `transact` runs AFTER the commit. The
219
+ * `lmid`-always-advances invariant is the HARNESS's, not the author's: {@link RindleApiServer.pushMutation}
220
+ * seals the response from this handle's recorded outcome, so an early return, a never-called
221
+ * `transact`, or a swallowed {@link MutationRejected} still advances `lmid` and never wedges the
222
+ * client's pending queue. */
223
+ export interface MutationScope {
224
+ /** Open the ONE atomic write transaction and drive `body` inside it, committing (stamping `lmid`
225
+ * co-transactionally) on a clean return. MAY be called at most once — a second call throws.
226
+ *
227
+ * Two forms:
228
+ * - `transact(sharedMutator, args, ctx)` — drive a SHARED (generator) mutator (the same body the
229
+ * client predicts); pass the already-parsed `args` and the server `ctx` (fold server-only
230
+ * values like a charge id into `ctx` here).
231
+ * - `transact(run)` — a raw callback receiving the live {@link ServerMutationTx} (the escape
232
+ * hatch: `tx.exec`, logical writes, read-your-writes reads).
233
+ *
234
+ * A THROW from the body that is not a {@link BackendError} is a BUSINESS rejection: the data rolls
235
+ * back, `lmid` advances alone, and this method throws {@link MutationRejected} (so surrounding
236
+ * code can compensate). A {@link BackendError} is INFRA: it propagates (the client retries). */
237
+ transact(run: (tx: ServerMutationTx) => void | Promise<void>): Promise<void>;
238
+ transact<A, C extends MutatorCtx>(mutator: SharedMutator<A, C>, args: A, ctx: C): Promise<void>;
239
+ }
240
+
241
+ /** A SCOPED server mutator (WORK-OUTSIDE-TX): server-only code, ONE `scope.transact`, optional
242
+ * post-commit code. Register it by wrapping in {@link scoped} — the tag the api-server routes on to
243
+ * hand it a {@link MutationScope} instead of running its whole body inside the transaction. */
244
+ export type ScopedMutator<User, Args> = (
245
+ scope: MutationScope,
246
+ args: Args,
247
+ ctx: MutationContext<User>,
248
+ ) => void | Promise<void>;
249
+
250
+ /** A {@link ScopedMutator} tagged by {@link scoped} so the harness invokes it with a
251
+ * {@link MutationScope}. Typed as a BRANDED tx-form {@link ApiMutator} purely so it registers in the
252
+ * `mutators` record without widening it to a union (which would break contextual inference for every
253
+ * plain tx-form entry). Its true runtime shape is `(scope, args, ctx)`; the tag — not the type —
254
+ * routes it, and it is never actually called as a tx-form mutator. */
255
+ export type ScopedApiMutator<User, Args> = ApiMutator<User, Args> & { readonly __rindleScoped: true };
256
+
257
+ /** Mark a mutator as SCOPED so the api-server gives it a {@link MutationScope} (author-controlled tx
258
+ * boundary via `scope.transact`) rather than running its whole body inside the transaction. Register
259
+ * it alongside the tx-form mutators — it wins by key like any override:
260
+ *
261
+ * ```ts
262
+ * mutators: defineApiMutators({
263
+ * ...sharedApiMutators(sharedMutators, sharedCtx), // tx-form (common case)
264
+ * createOrder: scoped(async (scope, raw, ctx) => { // needs outside-tx work
265
+ * const args = createOrder.args.parse(raw);
266
+ * const chargeId = await stripe.charge(args.amount, { idempotencyKey: ctx.envelope.mid }); // outside tx
267
+ * try {
268
+ * await scope.transact(createOrder, args, { ...sharedCtx(ctx), chargeId }); // inside tx
269
+ * } catch (e) {
270
+ * await stripe.refund(chargeId); // compensate — the write rejected
271
+ * throw e;
272
+ * }
273
+ * await sendReceipt(ctx.user); // after commit
274
+ * }),
275
+ * }),
276
+ * ```
277
+ */
278
+ export function scoped<User, Args>(fn: ScopedMutator<User, Args>): ScopedApiMutator<User, Args> {
279
+ // The brand carries the scoped runtime shape; typing the RETURN as the (branded) tx-form keeps it
280
+ // assignable into `mutators` WITHOUT unioning that record — the harness routes on the brand and
281
+ // never calls it as a tx-form mutator, so the cast is sound.
282
+ return Object.assign(fn, { __rindleScoped: true as const }) as unknown as ScopedApiMutator<User, Args>;
283
+ }
284
+
285
+ function isScoped<User>(m: ApiMutator<User, any>): m is ScopedApiMutator<User, any> {
286
+ return (m as { __rindleScoped?: boolean }).__rindleScoped === true;
287
+ }
288
+
152
289
  export type ApiMutators<User> = Record<string, ApiMutator<User, any>>;
153
290
 
154
291
  export interface QueryLeaseRequest<User> {
@@ -162,6 +299,95 @@ export interface QueryLeaseRequest<User> {
162
299
  clientId?: string;
163
300
  }
164
301
 
302
+ /**
303
+ * The room-serve block on a query lease (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 step 5 / §2.4,
304
+ * slice G-iv-b): present only when the named query carries a realtime label AND its resolved AST
305
+ * was PROVABLY covered by its room profile's footprint (the daemon's `/cover-check`). The G-v
306
+ * client uses it to open the room transport for THIS query beside — never instead of — its daemon
307
+ * session.
308
+ *
309
+ * It is a NEW dedicated block on purpose: the TOP-LEVEL `wsEndpoint` is the read-router's
310
+ * whole-session migration signal (`packages/remote/src/optimistic-source.ts` — a lease whose
311
+ * `wsEndpoint` differs migrates the client's ENTIRE ws session), so the room endpoint must never
312
+ * ride that field. A room-served lease's top-level fields are byte-identical to the daemon-served
313
+ * ones.
314
+ */
315
+ export interface QueryLeaseRealtime {
316
+ /** The client store's gate/domain key for this room source (`connectSource`) AND the string the
317
+ * wasm engine's `parse_source_key` accepts: any string other than the reserved `"daemon"`
318
+ * parses as a room source, and the established convention is `"room:" + doc`
319
+ * (e.g. `room:document/doc:d1`). */
320
+ sourceKey: string;
321
+ /** Where the client opens the ROOM ws for this query (from `realtime.locateRoom`) — a separate
322
+ * field from the top-level `wsEndpoint` (see above: no whole-session migration). */
323
+ wsEndpoint: string;
324
+ /** The room's self-authorizing signed lease (`@rindle/room/token`): the APPROVED query AST +
325
+ * doc + subject, HMAC-signed with `realtime.roomTokenKey` so the room shell's
326
+ * `downstream.tokenKeys` ring verifies it. The room materializes on first presentation. */
327
+ roomToken: string;
328
+ /** Token expiry (ms epoch) — the client's renewal clock (renewal = a fresh lease). */
329
+ exp: number;
330
+ /** The wire room doc (`"<profile>/<key>"`, minted server-side — never client-derived). */
331
+ doc: string;
332
+ /** Per-footprint-table specs: the §2.2 owned/followed split + §3.2 routing metadata. Since
333
+ * H-iii each spec also carries `footprintWhere` — the same exact membership predicate the boot
334
+ * wire ships the room gate (one compiler, `compileRoomScopeSpecs`) — feeding the client's §3
335
+ * prove-or-slow-path router. Advisory routing metadata, never a credential (§3.2). */
336
+ tables: RoomTableSpec[];
337
+ }
338
+
339
+ /**
340
+ * One minted SYSTEM-STREAM lease on a query lease's `lifecycle` block (RINDLE-REALTIME-QUERY-
341
+ * ENABLEMENT §4, Slice I-iii): an ordinary daemon materialization over one of the four
342
+ * `_rindle_*` lifecycle system tables (registered by the daemon's `enable_realtime_lifecycle` —
343
+ * `rust/rindle-replica/src/mutations.rs`), attachable by the EXISTING client subscribe path
344
+ * (present `leaseToken` on a `subscribe` frame, exactly like the primary lease). The identity
345
+ * fields (`scope`/`doc`/`clientId`) document the minted AST's predicate — the client keys its
346
+ * retains and its release-time row filters on them.
347
+ */
348
+ export interface QueryLeaseLifecycleLease {
349
+ /** Which system table this lease's subscription serves. */
350
+ table: string;
351
+ leaseToken: string;
352
+ /** The follower this system materialization lives on (routed deploys; mirrors the top-level
353
+ * `wsEndpoint` semantics). Absent ⇒ single-daemon. */
354
+ wsEndpoint?: string;
355
+ /** DOORBELL only: the §4.1 occupancy scope — the wire room doc (`"<profile>/<key>"`). */
356
+ scope?: string;
357
+ /** FENCE entries only: the room doc the predicate is scoped to. */
358
+ doc?: string;
359
+ /** FENCE ledger/outcome entries: present iff the predicate was ALSO client-scoped (the lease
360
+ * request carried `clientId`). Absent ⇒ doc-only predicate — the client filters to its own
361
+ * rows regardless (defense in depth). */
362
+ clientId?: string;
363
+ }
364
+
365
+ /** The §4 lifecycle block on a query lease (Slice I-iii): present only when BOTH the realtime
366
+ * `lifecycle` config is on AND the query is realtime-labeled. `doorbell` rides EVERY labeled
367
+ * lease (occupancy is counted whether or not the query is room-served — the 1→2 upgrade trigger
368
+ * needs solo watchers subscribed BEFORE any room exists, §4.1); `fence` rides only a ROOM-SERVED
369
+ * lease (the §4.2/§7.1/§3.3 downgrade surfaces are meaningful only where a room domain exists).
370
+ * The §4.2 fence VALUE (`finalFlushSeq`) is deliberately NOT here — it arrives with the I-v
371
+ * downgrade response; I-iii only stands up the streams. */
372
+ export interface QueryLeaseLifecycle {
373
+ doorbell: QueryLeaseLifecycleLease;
374
+ fence?: QueryLeaseLifecycleLease[];
375
+ }
376
+
377
+ /** The §4.2 downgrade fence on a query lease (Slice I-v): rides a labeled reply whose §4.1
378
+ * occupancy gate CLOSED (so there is NO `realtime` block) when the server could drain the room.
379
+ * A SIBLING of `realtime`, never nested inside it — block-ABSENCE is the downgrade signal, and
380
+ * the fence rides alongside that absence. Its `finalFlushSeq` is the room's last COMMITTED flush
381
+ * seq; the client's frozen room ghost holds visible until the daemon plane has provably absorbed
382
+ * it (`_rindle_room_watermark(doc) ≥ finalFlushSeq`). Absent from every non-downgrade reply. */
383
+ export interface QueryLeaseRealtimeFence {
384
+ /** The retiring room source's gate/domain key — `"room:" + doc`, matching what the room-served
385
+ * lease's {@link QueryLeaseRealtime.sourceKey} carried. */
386
+ sourceKey: string;
387
+ doc: string;
388
+ finalFlushSeq: number;
389
+ }
390
+
165
391
  export interface QueryLeaseResponse {
166
392
  leaseToken: string;
167
393
  materializationId: string;
@@ -170,6 +396,17 @@ export interface QueryLeaseResponse {
170
396
  /** The follower this lease lives on (READ-ROUTER-DESIGN.md §2.3) — the browser opens its
171
397
  * subscription ws here. Absent ⇒ single-daemon (the client uses its static `wsUrl`). */
172
398
  wsEndpoint?: string;
399
+ /** The room-serve block (G-iv-b) — see {@link QueryLeaseRealtime}. Absent ⇒ the lease is
400
+ * byte-identical to the legacy daemon-served shape. */
401
+ realtime?: QueryLeaseRealtime;
402
+ /** The §4.2 downgrade fence (Slice I-v) — see {@link QueryLeaseRealtimeFence}. Present only on a
403
+ * labeled reply whose occupancy gate closed AND `realtime.lifecycle.drainRoom` could drain the
404
+ * room; absent otherwise (including on every room-served reply). */
405
+ realtimeFence?: QueryLeaseRealtimeFence;
406
+ /** The §4 lifecycle system-stream block (Slice I-iii) — see {@link QueryLeaseLifecycle}.
407
+ * Minted ONLY under the opt-in `realtime.lifecycle` config; absent ⇒ byte-identical to the
408
+ * pre-lifecycle response. */
409
+ lifecycle?: QueryLeaseLifecycle;
173
410
  }
174
411
 
175
412
  /** A one-shot SSR read of a named query (SSR-DESIGN.md §6): same `(name, args)` surface as a lease,
@@ -311,6 +548,12 @@ export interface RoomBootResponse {
311
548
  /** Where the room opens its upstream subscription (a routed deploy's follower). Absent ⇒ the
312
549
  * shell's statically configured rindled ws endpoint. */
313
550
  upstreamWsEndpoint?: string;
551
+ /** Per-footprint-table scope specs (H-iv-b), compiled from the resolved footprint AST + the
552
+ * profile's context set (the legacy anonymous profile compiles with an empty context set):
553
+ * what the shell hands the wasm room's `enableWritesV2` — the §3.3 commit gate. Optional
554
+ * only for wire compatibility with pre-H-iv-b servers; a shell that doesn't receive them
555
+ * enables the v1 table-granular write plane exactly as before. */
556
+ scopes?: RoomScopeSpec[];
314
557
  flush: RoomBootFlush;
315
558
  }
316
559
 
@@ -319,11 +562,22 @@ export interface RindleRealtimeOptions<User> {
319
562
  * room's `Authorization: Bearer` on `/room-boot` (the default {@link authorizeBoot}) and keys
320
563
  * the DEFAULT epoch-bound flush credential. */
321
564
  shellSecret: string;
565
+ /** NAMED room profiles (RINDLE-REALTIME-QUERY-ENABLEMENT-DESIGN.md §2.1): profile name → key
566
+ * derivation + canonical unwindowed footprint + read-only context tables. The wire room key
567
+ * for a named profile is `"<profile>/<key>"`; `/room-boot` splits it and resolves the
568
+ * profile's footprint with the bare key. Compiled + validated LOUDLY at construction (§2.3):
569
+ * a windowed footprint, a context table missing from the schema/footprint, or a registered
570
+ * query whose realtime label names a missing profile all throw from `createRindleApiServer`. */
571
+ rooms?: Record<string, RoomProfile<User>>;
322
572
  /** doc → the room's approved upstream footprint (§3.1) — an `Ast` or fluent `Query`. MAY
323
573
  * delegate to the named-query registry internally; throw `RindleApiError("not-found", …, 404)`
324
574
  * for a doc that shouldn't exist. The §9 footprint budget belongs here — it runs once per
325
- * placement, at lease mint. */
326
- resolveFootprint: (doc: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;
575
+ * placement, at lease mint.
576
+ * @deprecated Prefer named {@link rooms} profiles (RINDLE-REALTIME-QUERY-ENABLEMENT §2.1).
577
+ * This bare form remains as the single-profile LEGACY alias — the anonymous/default profile:
578
+ * a doc with no known `"<profile>/"` prefix resolves here, byte-identically to before named
579
+ * profiles existed (and with none of their construction/boot-time validation). */
580
+ resolveFootprint?: (doc: string, ctx: ApiContext<User>) => MaybePromise<ApiQueryResult>;
327
581
  /** Gates the flush trio — `authorizeRoom`, relocated. Default: verify the default flush
328
582
  * credential from the {@link ROOM_FLUSH_CREDENTIAL_HEADER} request header — which requires the
329
583
  * transport to pass its incoming request as `context.request` (Fetch `Request` and node
@@ -342,6 +596,72 @@ export interface RindleRealtimeOptions<User> {
342
596
  /** Static override for where rooms open their upstream subscription; default is the
343
597
  * `wsEndpoint` the materialize returns (set in routed deploys), else absent. */
344
598
  upstreamWsEndpoint?: string;
599
+ /** Locate (or place) the room serving `doc` and return the ROOM ws endpoint a room-served
600
+ * lease's client should open (G-iv-b; on the DO shell this is the Worker's room URL). The
601
+ * endpoint rides the lease's dedicated `realtime.wsEndpoint` — NEVER the top-level
602
+ * `wsEndpoint` (that field migrates the client's whole daemon session). Absent ⇒ room-serving
603
+ * is OFF: labeled queries serve from the daemon exactly as today (fail-open). */
604
+ locateRoom?: (doc: string) => MaybePromise<{ wsEndpoint: string }>;
605
+ /** The room lease token signing key (`@rindle/room/token`): `kid` + secret, matching an entry
606
+ * in the room shell's `downstream.tokenKeys` ring. Required for room-serving (without it a
607
+ * labeled query fail-opens to the daemon with a one-time warning). A separate secret from
608
+ * `shellSecret` on purpose — the shell's ring is the client-token trust domain, the shell
609
+ * secret is the boot/flush trust domain. */
610
+ roomTokenKey?: { kid: string; secret: string };
611
+ /** Room lease token TTL, ms (default 5 minutes — the §4.1 short-TTL backstop; renewal is a
612
+ * fresh lease through this server, never an extension). */
613
+ roomTokenTtlMs?: number;
614
+ /** Loud-diagnostics sink for the realtime layer (profile compilation warnings + the one-time
615
+ * per-(query, profile) "not room-served" serve-decision warnings). Defaults to
616
+ * `console.warn`; injectable for tests. */
617
+ warn?: (message: string) => void;
618
+ /** The §4 upgrade/downgrade lifecycle plane (RINDLE-REALTIME-QUERY-ENABLEMENT §4, Slice
619
+ * I-iii): PRESENCE of this block is the opt-in — every realtime-labeled lease then
620
+ * additionally mints the doorbell system lease (occupancy, §4.1) and every ROOM-SERVED lease
621
+ * the fence bundle (watermark + ledger + outcomes, §4.2/§7.1/§3.3) — see
622
+ * {@link QueryLeaseLifecycle}. Requires the daemon to have run `enable_realtime_lifecycle`
623
+ * (the four `_rindle_*` system tables must be registered or the minted materializations fail
624
+ * — which fail-opens with a one-time warning, never blocking the lease). Absent ⇒ the lease
625
+ * response is byte-identical to pre-lifecycle. */
626
+ lifecycle?: RindleRealtimeLifecycleOptions;
627
+ }
628
+
629
+ /** {@link RindleRealtimeOptions.lifecycle}. PRESENCE of the block is the opt-in switch (I-iii);
630
+ * the fields below are the Slice I-iv occupancy knobs (§4.1, decisions D4/D6/D7). All optional —
631
+ * `lifecycle: {}` gets the designed defaults. */
632
+ export interface RindleRealtimeLifecycleOptions {
633
+ /** D6 (§4.1): the occupancy threshold for room-serving. A labeled lease whose scope counts
634
+ * FEWER than this many distinct unexpired sessions (the caller's own included) ships WITHOUT
635
+ * the realtime block — served from the daemon, indistinguishable from an uncovered query —
636
+ * but WITH the doorbell, so the 1→2 transition wakes it (that is the point: solo docs never
637
+ * cost room infrastructure). Default **2** (the design's 1→2 trigger). Set `1` to room-serve
638
+ * solo viewers (the pre-I-iv behavior under lifecycle config). */
639
+ minSessions?: number;
640
+ /** The §9.1 hysteresis window, ms (default **120_000**). Two consumers: (a) the lazy sweep
641
+ * (D4) keeps expired session rows lingering at least this long past expiry — Slice I-v's
642
+ * downgrade decision ("no other unexpired row AND the newest other row expired > graceMs
643
+ * ago") is read FROM those rows, so they must survive to be read; (b) this slice's gate
644
+ * applies the same hysteresis upward: a scope with an other-session row expired ≤ graceMs
645
+ * ago keeps room-serving through the window (see `lifecycleOccupancy` — no flap on one
646
+ * client's brief lapse). §9.1-tunable: raise it for docs where collaborators churn slowly. */
647
+ graceMs?: number;
648
+ /** TTL of an occupancy session row, ms — `expires_at = now + sessionTtlMs` on every labeled
649
+ * lease mint/renewal (D7: session identity = the request's `clientId`; two tabs are two
650
+ * sessions iff their clientIds differ). Default = the server's `leaseTtlMs`, else 5 minutes —
651
+ * matching the room-token renewal cadence (`roomTokenTtlMs`, renewed 30s early), so a
652
+ * room-attached client's renewals keep its row unexpired; a daemon-attached solo client's row
653
+ * MAY lapse (it has no renewal timer) and is refreshed by its next doorbell-triggered
654
+ * re-lease — occupancy converges through the doorbell itself. */
655
+ sessionTtlMs?: number;
656
+ /** The §4.2 downgrade drain hook (Slice I-v). When the occupancy gate CLOSES for a labeled
657
+ * lease whose scope PLAUSIBLY hosted a room (an other-session row still lingers — never a
658
+ * never-shared solo doc), the api-server calls this to drain the room's pending write-behind
659
+ * and learn its last COMMITTED `flush_seq`, then rides the value back on the lease as the
660
+ * {@link QueryLeaseRealtimeFence}. The deployment wires it to the room shell's / DO's `/drain`
661
+ * control. Absent ⇒ no fence is attached (the client hits its loud legacy downgrade path);
662
+ * a throw fails OPEN to the same (a downgrade never blocks the lease). Concurrent drains across
663
+ * api-server instances are fine — `/drain` is idempotent. */
664
+ drainRoom?: (doc: string) => Promise<{ finalFlushSeq: number }>;
345
665
  }
346
666
 
347
667
  // The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
@@ -510,6 +830,12 @@ export interface RindleApiServerOptions<User> {
510
830
  /** The user context pins resolve under (pins are shared, so they should not depend on a
511
831
  * per-viewer identity). Defaults to `undefined`. */
512
832
  pinUser?: User;
833
+ /** Surfaced when a SCOPED mutator ({@link scoped}) throws from code that runs AFTER `scope.transact`
834
+ * has already sealed the protocol outcome — a post-commit effect, or a compensation handler running
835
+ * after a business rejection. The outcome is fixed (this callback CANNOT change the client's
836
+ * response or the `lmid` advance), but the throw must not vanish: a failed refund is real money.
837
+ * Absent ⇒ the error is logged to `console.error`. */
838
+ onScopeError?: (err: unknown, info: { phase: "committed" | "rejected"; envelope: MutationEnvelope }) => void;
513
839
  }
514
840
 
515
841
  export interface RindleApiServer<User> {
@@ -520,6 +846,18 @@ export interface RindleApiServer<User> {
520
846
  * startup and whenever the daemon restarts (e.g. from the daemon-client `onBootId` hook), since
521
847
  * the daemon holds no durable materialization state. No-op when `pinnedQueries` is empty. */
522
848
  assertPins(): Promise<void>;
849
+ /** The room-serving coverage DIAGNOSTIC (G-iv-b; the `assertPins`-style explicit check): for
850
+ * every realtime-labeled query, resolve it (under `pinUser`, per exemplar args), resolve its
851
+ * profile's footprint, and run the REAL daemon coverage check — the same verdict the lease
852
+ * path serves by. Returns every verdict; `strict` throws when any labeled query is not
853
+ * provably covered (deploy-gate mode). Deliberately ignores `locateRoom`/`roomTokenKey` — it
854
+ * answers "would this query be coverable", not "is serving fully wired". */
855
+ validateRealtime(opts?: {
856
+ /** Per-query exemplar args (a query is checked once per exemplar; default: one `null`). */
857
+ exemplars?: Partial<Record<string, readonly unknown[]>>;
858
+ /** Throw when any labeled query is uncovered (instead of just reporting). */
859
+ strict?: boolean;
860
+ }): Promise<ValidateRealtimeReport>;
523
861
  pushMutation(input: PushMutationRequest<User>): Promise<PushMutationResponse>;
524
862
  /** Apply an in-order batch (the client mutation queue's flush). Envelopes run strictly
525
863
  * sequentially; a rejection still advances the daemon's lmid, so later envelopes in the
@@ -558,6 +896,24 @@ export interface RindleApiServer<User> {
558
896
  handleRoomBootJson(body: unknown, context: ApiContext<User>): Promise<RoomHostResponse>;
559
897
  }
560
898
 
899
+ /** One {@link RindleApiServer.validateRealtime} verdict: a labeled query × exemplar args. */
900
+ export interface ValidateRealtimeVerdict {
901
+ query: string;
902
+ profile: string;
903
+ args: unknown;
904
+ covered: boolean;
905
+ /** Why it is not covered (uncovered verdicts only) — the daemon's reason strings, the
906
+ * aggregate refusal, or a resolve/footprint error message. */
907
+ reasons?: string[];
908
+ }
909
+
910
+ /** The {@link RindleApiServer.validateRealtime} report. */
911
+ export interface ValidateRealtimeReport {
912
+ verdicts: ValidateRealtimeVerdict[];
913
+ /** The uncovered subset of `verdicts` (what `strict` throws on). */
914
+ uncovered: ValidateRealtimeVerdict[];
915
+ }
916
+
561
917
  export type RindleApiErrorCode = "bad-request" | "forbidden" | "not-found" | "rejected";
562
918
 
563
919
  export class RindleApiError extends Error {
@@ -652,6 +1008,13 @@ export class SplitDaemonClient implements RindleDaemonClient {
652
1008
  if (!lmids) return Promise.reject(new Error("the write master lacks roomLmids"));
653
1009
  return lmids(input);
654
1010
  }
1011
+ // Pure computation, but routed to the MASTER like the rest of the room ops: the master is a
1012
+ // real rindled that hosts `/cover-check`; the read router may not proxy it.
1013
+ coverQuery(input: CoverQueryInput): Promise<CoverQueryOutput> {
1014
+ const cover = this.writes.coverQuery?.bind(this.writes);
1015
+ if (!cover) return Promise.reject(new Error("the write master lacks coverQuery"));
1016
+ return cover(input);
1017
+ }
655
1018
  migrate(input: MigrateInput): Promise<MigrateOutput> {
656
1019
  return this.writes.migrate(input);
657
1020
  }
@@ -1390,7 +1753,11 @@ export function registerQueries<User>(queries: readonly NamedQuery<any, any, any
1390
1753
  if (Object.prototype.hasOwnProperty.call(out, query.queryName)) {
1391
1754
  throw new Error(`registerQueries: duplicate query name "${query.queryName}"`);
1392
1755
  }
1393
- out[query.queryName] = (ctx, args) => query.resolve(args, ctx);
1756
+ const wrapped: ApiQuery<User, any> = (ctx, args) => query.resolve(args, ctx);
1757
+ // The §2.1 realtime label survives this seam (read it back with {@link queryRealtimeLabel}) —
1758
+ // the lease path looks up (room profile, args mapping) by query name. Unlabeled queries get
1759
+ // the exact bare wrapper they always did.
1760
+ out[query.queryName] = query.realtime === undefined ? wrapped : attachRealtimeLabel(wrapped, query.realtime);
1394
1761
  }
1395
1762
  return out;
1396
1763
  }
@@ -1431,11 +1798,47 @@ export function sharedApiMutators<User>(
1431
1798
  return out;
1432
1799
  }
1433
1800
 
1434
- export function queryResultToAst(result: ApiQueryResult): Ast {
1435
- if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
1436
- return result.ast();
1437
- }
1438
- return result as Ast;
1801
+ /**
1802
+ * Wrap a SHARED (generator) mutator with a row-level ACCESS GUARD the multi-tenant authz twin of
1803
+ * {@link sharedApiMutators}. It parses the untrusted wire args, derives the {@link MutatorCtx}
1804
+ * principal (the SAME mapping you pass to `sharedApiMutators`), evaluates `predicate` against the OPEN
1805
+ * mutation txn (so it can READ the rows the write depends on), and throws `forbidden` (403 — the
1806
+ * client's optimistic write snaps back) when access is denied; otherwise it drives the SAME body the
1807
+ * client predicts ({@link runSharedMutation}). Use it for the entries that need server-only authority
1808
+ * the client cannot predict, OVERRIDING the auto-wrapped default (spread `sharedApiMutators(...)`
1809
+ * first, then the guarded overrides win by key):
1810
+ *
1811
+ * ```ts
1812
+ * const principal = (ctx) => ({ user: requireUser(ctx.user) });
1813
+ * mutators: defineApiMutators({
1814
+ * ...sharedApiMutators(sharedMutators, principal),
1815
+ * updateSlide: guardMutator(sharedMutators.updateSlide, principal,
1816
+ * async (tx, a, { user }) =>
1817
+ * (await tx.query(q.slide.where.id(a.slideId).where(editableBy(user)).one())) != null,
1818
+ * { message: "not permitted to edit this slide" }),
1819
+ * }),
1820
+ * ```
1821
+ *
1822
+ * The predicate keeps the shared body READ-FREE, so the client's `.folded` hot paths (drag/keystroke)
1823
+ * still fold — the read is server-side only. Return `false` to deny (→ the default or `opts.message`
1824
+ * forbidden); return `true`/nothing to allow. To reject with a different status/message (a business
1825
+ * rejection, a not-found), throw a {@link RindleApiError} from inside the predicate instead. `principal`
1826
+ * runs before the predicate, so it too may throw `forbidden` for an anonymous caller.
1827
+ */
1828
+ export function guardMutator<User, Args>(
1829
+ gen: SharedMutatorWithArgs<Args>,
1830
+ principal: (ctx: MutationContext<User>) => MutatorCtx,
1831
+ predicate: (tx: ServerMutationTx, args: Args, ctx: MutatorCtx) => boolean | void | Promise<boolean | void>,
1832
+ opts?: { message?: string },
1833
+ ): ApiMutator<User, unknown> {
1834
+ return async (tx, raw, ctx) => {
1835
+ const args = gen.args.parse(raw);
1836
+ const pctx = principal(ctx);
1837
+ if ((await predicate(tx, args, pctx)) === false) {
1838
+ throw new RindleApiError("forbidden", opts?.message ?? "not permitted", 403);
1839
+ }
1840
+ return runSharedMutation(gen, args, pctx, tx);
1841
+ };
1439
1842
  }
1440
1843
 
1441
1844
  /** One exemplar invocation for {@link dumpQueryShapes} — the `args`/`user` a query is built with.
@@ -1472,8 +1875,9 @@ export async function dumpQueryShapes<User>(opts: {
1472
1875
  exemplars?: Partial<Record<string, ReadonlyArray<ShapeExemplar<User>>>>;
1473
1876
  }): Promise<QueryShapesDoc> {
1474
1877
  const tables = Object.values(opts.schema.tables)
1475
- // Local-only tables live in the browser's memory source, never a TableSource — no indexes.
1476
- .filter((t) => t.local !== true)
1878
+ // Local-only tables (both `true` and `"session"`) live in the browser's memory source,
1879
+ // never a TableSource — no indexes.
1880
+ .filter((t) => !t.local)
1477
1881
  .map((t) => ({ name: t.name, primaryKey: [...t.primaryKey] }))
1478
1882
  .sort((a, b) => a.name.localeCompare(b.name));
1479
1883
  const queries: QueryShapesDoc["queries"] = [];
@@ -1535,6 +1939,88 @@ function normalizeCondition(c: Condition): unknown {
1535
1939
  }
1536
1940
  }
1537
1941
 
1942
+ /**
1943
+ * The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
1944
+ * transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
1945
+ * mutator uses), but lets the AUTHOR decide when it opens, so server-only work can run outside it.
1946
+ *
1947
+ * It records its outcome so the harness — not the author — enforces the `lmid`-always-advances
1948
+ * invariant: `phase` reports whether the tx committed, business-rejected, or never ran, and `infra`
1949
+ * latches a backend (DB) failure. Because the backend's `runMutation` RETURNS `{accepted:false}` for
1950
+ * a business rejection (having already advanced `lmid` alone) and THROWS only for infra, `transact`
1951
+ * can cleanly re-throw {@link MutationRejected} on the former (for author compensation) and propagate
1952
+ * the raw driver error on the latter.
1953
+ */
1954
+ class MutationScopeImpl implements MutationScope {
1955
+ private attempted = false;
1956
+ private readonly backend: MutationBackend;
1957
+ private readonly envelope: MutationEnvelope;
1958
+ private readonly render: RenderIndex;
1959
+ /** Set once `transact` resolved through the backend (accepted OR business-rejected). */
1960
+ outcome?: MutationOutcome;
1961
+ /** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
1962
+ * advance. Its presence is tracked by {@link infraLatched}, NOT by testing this for `undefined`:
1963
+ * a driver may legitimately reject with a falsy value, and misreading that as "no infra" would
1964
+ * reclassify a lost-connection failure as a business rejection and wrongly advance `lmid`. */
1965
+ infra?: unknown;
1966
+ /** True once an INFRA failure latched, regardless of its (possibly falsy) value. */
1967
+ infraLatched = false;
1968
+ /** The in-flight `transact` promise. `settle` awaits it before sealing, so a transact the author
1969
+ * FORGOT to await (a floating promise — nothing here lints against it) is still resolved to its
1970
+ * real outcome first; otherwise the seal would read `untouched`, reply with a phantom no-op, and
1971
+ * let the real write commit out-of-band after the response was already sent. */
1972
+ pending?: Promise<void>;
1973
+
1974
+ constructor(backend: MutationBackend, envelope: MutationEnvelope, render: RenderIndex) {
1975
+ this.backend = backend;
1976
+ this.envelope = envelope;
1977
+ this.render = render;
1978
+ }
1979
+
1980
+ transact(
1981
+ first: SharedMutator<any, any> | ((tx: ServerMutationTx) => void | Promise<void>),
1982
+ args?: unknown,
1983
+ ctx?: MutatorCtx,
1984
+ ): Promise<void> {
1985
+ if (this.attempted) throw new Error("scope.transact may be called at most once per mutation");
1986
+ this.attempted = true;
1987
+ const promise = this.drive(first, args, ctx);
1988
+ // Record the in-flight promise so `settle` can await it even when the author didn't. Errors are
1989
+ // latched onto `this` (outcome / infra), so this tracking copy swallows them — the author's
1990
+ // returned `promise` still rejects for them to await/catch.
1991
+ this.pending = promise.then(
1992
+ () => undefined,
1993
+ () => undefined,
1994
+ );
1995
+ return promise;
1996
+ }
1997
+
1998
+ private async drive(
1999
+ first: SharedMutator<any, any> | ((tx: ServerMutationTx) => void | Promise<void>),
2000
+ args?: unknown,
2001
+ ctx?: MutatorCtx,
2002
+ ): Promise<void> {
2003
+ // A shared (generator) mutator is driven via the isomorphic seam; a plain callback gets the raw tx.
2004
+ const run = isGeneratorMutator(first)
2005
+ ? async (tx: ServerMutationTx) => {
2006
+ await runSharedMutation(first as SharedMutator<unknown, MutatorCtx>, args, ctx as MutatorCtx, tx);
2007
+ }
2008
+ : async (tx: ServerMutationTx) => {
2009
+ await (first as (tx: ServerMutationTx) => void | Promise<void>)(tx);
2010
+ };
2011
+ let outcome: MutationOutcome;
2012
+ try {
2013
+ outcome = await this.backend.runMutation({ envelope: this.envelope, render: this.render, run });
2014
+ } catch (err) {
2015
+ this.infra = err; // the backend throws ONLY for infra; a business rejection returns {accepted:false}
2016
+ this.infraLatched = true;
2017
+ throw err;
2018
+ }
2019
+ this.outcome = outcome;
2020
+ if (!outcome.accepted) throw new MutationRejected(outcome.reason);
2021
+ }
2022
+ }
2023
+
1538
2024
  export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptions<User>): RindleApiServer<User> {
1539
2025
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
1540
2026
  const mode = opts.mode ?? "normalized";
@@ -1548,6 +2034,25 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1548
2034
  // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
1549
2035
  const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
1550
2036
 
2037
+ // Rindle Realtime declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a):
2038
+ // compile the named room profiles and run every "loud at registration" (§2.3) check NOW —
2039
+ // construction is the moment a misconfigured profile or label can still fail the deploy,
2040
+ // not a 3am room boot. The legacy flat `resolveFootprint` stays the anonymous profile and
2041
+ // is deliberately NOT probed or validated (byte-identical legacy behavior).
2042
+ const realtime = opts.realtime;
2043
+ const roomProfiles = compileRoomProfiles<User>({
2044
+ rooms: realtime?.rooms,
2045
+ schema: opts.schema,
2046
+ warn: realtime?.warn,
2047
+ });
2048
+ assertLabeledProfilesExist(opts.queries, roomProfiles);
2049
+ if (realtime !== undefined && roomProfiles.size === 0 && realtime.resolveFootprint === undefined) {
2050
+ throw new Error(
2051
+ "realtime: configure at least one room profile (realtime.rooms) or the legacy resolveFootprint — " +
2052
+ "a realtime host with neither can never boot a room.",
2053
+ );
2054
+ }
2055
+
1551
2056
  // Resolve a named query (+ args) to its AST under a given context — the shared path for both
1552
2057
  // a per-viewer lease and a system-level pin (which skips per-user authorization).
1553
2058
  const resolveAst = async (name: string, args: unknown, context: ApiContext<User>): Promise<Ast> => {
@@ -1559,6 +2064,372 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1559
2064
  return queryResultToAst(result);
1560
2065
  };
1561
2066
 
2067
+ // ---------------------------------------------------------------- the room-serve decision
2068
+ //
2069
+ // RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 lease-flow steps 2–5, slice G-iv-b. Everything here is
2070
+ // FAIL-OPEN: any missing wiring, refused proof, or thrown error means the lease is served from
2071
+ // the daemon EXACTLY as today (no `realtime` block, top-level fields untouched) plus a one-time
2072
+ // diagnostic — a coverage/config problem must never block a lease.
2073
+
2074
+ const realtimeWarn = realtime?.warn ?? ((message: string) => console.warn(message));
2075
+ // One-time per (queryName, profile): the serve decision runs on EVERY lease, so an uncovered
2076
+ // labeled query would otherwise warn once per viewer per mount.
2077
+ const warnedRoomServe = new Set<string>();
2078
+ const warnRoomServeOnce = (queryName: string, profile: string, reasons: readonly string[]): void => {
2079
+ const key = `${queryName}\u0000${profile}`;
2080
+ if (warnedRoomServe.has(key)) return;
2081
+ warnedRoomServe.add(key);
2082
+ realtimeWarn(
2083
+ `query "${queryName}" is labeled realtime (room profile "${profile}") but is NOT room-served — ` +
2084
+ `${reasons.join("; ")}. It serves from the daemon (correct, just not room-accelerated). ` +
2085
+ `This warning fires once per (query, profile).`,
2086
+ );
2087
+ };
2088
+
2089
+ // The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
2090
+ // an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
2091
+ const AGGREGATE_REFUSAL =
2092
+ "the query AST contains an aggregate/reduce shape — aggregate overlays are daemon-gated until post-G";
2093
+
2094
+ // Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
2095
+ // AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
2096
+ // (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
2097
+ // separate slot precisely because anything that changes the verdict must change one of the two
2098
+ // ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
2099
+ // `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
2100
+ // Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
2101
+ const coverVerdicts = new Map<string, CoverQueryOutput>();
2102
+ const COVER_VERDICT_CACHE_MAX = 1024;
2103
+
2104
+ const maybeRoomServe = async (
2105
+ input: QueryLeaseRequest<User>,
2106
+ queryAst: Ast,
2107
+ context: ApiContext<User>,
2108
+ subject: string | undefined,
2109
+ ): Promise<QueryLeaseRealtime | undefined> => {
2110
+ // (a) the label + (b) its profile — the fast bail keeps unlabeled leases byte-identical.
2111
+ const label = queryRealtimeLabel(opts.queries?.[input.name]);
2112
+ if (label === undefined) return undefined;
2113
+ const profile = roomProfiles.get(label.room);
2114
+ if (profile === undefined) return undefined; // unreachable: construction asserted it exists
2115
+ try {
2116
+ // (c) the wiring gates — each absence fail-opens with a one-time, named reason.
2117
+ if (realtime?.locateRoom === undefined) {
2118
+ warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
2119
+ return undefined;
2120
+ }
2121
+ const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
2122
+ if (coverQuery === undefined) {
2123
+ warnRoomServeOnce(input.name, profile.name, [
2124
+ "the configured daemon client does not implement coverQuery (/cover-check)",
2125
+ ]);
2126
+ return undefined;
2127
+ }
2128
+ const tokenKey = realtime.roomTokenKey;
2129
+ if (tokenKey === undefined) {
2130
+ warnRoomServeOnce(input.name, profile.name, [
2131
+ "realtime.roomTokenKey is not configured — the room lease token cannot be signed",
2132
+ ]);
2133
+ return undefined;
2134
+ }
2135
+ // The token's subject: the same resolved subject the daemon lease carries, else the
2136
+ // browser's clientId. The shell refuses a subject-less token, so with neither we fail open.
2137
+ const sub = subject ?? input.clientId;
2138
+ if (sub === undefined) {
2139
+ warnRoomServeOnce(input.name, profile.name, [
2140
+ "no token subject — configure `subject` (or have the client send clientId)",
2141
+ ]);
2142
+ return undefined;
2143
+ }
2144
+
2145
+ // §2.1: (roomProfile, roomArgs) via the label's args mapping; key + doc minted SERVER-side
2146
+ // (input.args just passed the query's own validation inside resolveAst).
2147
+ const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
2148
+ const key = profile.key(roomArgs);
2149
+ const doc = mintRoomDoc(profile.name, key);
2150
+
2151
+ // The profile footprint for THIS key under the request ctx (works for non-static
2152
+ // profiles), with the §2.3 unwindowed backstop `/room-boot` also applies.
2153
+ const footprintAst = queryResultToAst(await profile.footprint(key, context));
2154
+ assertUnwindowedFootprint(footprintAst, profile.name);
2155
+
2156
+ const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
2157
+ let verdict = coverVerdicts.get(verdictKey);
2158
+ if (verdict === undefined) {
2159
+ verdict = astHasAggregate(queryAst)
2160
+ ? { covered: false, reasons: [AGGREGATE_REFUSAL] }
2161
+ : await coverQuery({ footprint: footprintAst, query: queryAst });
2162
+ // (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
2163
+ // a transient daemon failure doesn't pin an uncovered verdict.)
2164
+ if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
2165
+ coverVerdicts.delete(coverVerdicts.keys().next().value as string);
2166
+ }
2167
+ coverVerdicts.set(verdictKey, verdict);
2168
+ }
2169
+ if (!verdict.covered) {
2170
+ warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
2171
+ return undefined;
2172
+ }
2173
+
2174
+ // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field — the
2175
+ // top-level `wsEndpoint` stays exactly the daemon lease's (whole-session migration signal).
2176
+ const { wsEndpoint } = await realtime.locateRoom(doc);
2177
+ const now = Date.now();
2178
+ const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
2179
+ const { mintRoomToken, scopeSpecsHash } = await loadRoomTokenModule();
2180
+ // The lease-wire specs, hashed ONCE: the same value is stamped on the token (so the
2181
+ // shell can flag scope skew — a profile edited under a live room, whose gate armed
2182
+ // with the OLD specs at boot) and returned as the client's `tables`.
2183
+ const tables = compileRoomTableSpecs(footprintAst, profile.context);
2184
+ const roomToken = await mintRoomToken({
2185
+ doc,
2186
+ ast: queryAst, // the APPROVED resolved AST — the client carries it, it can't mint/alter it
2187
+ sub,
2188
+ kid: tokenKey.kid,
2189
+ key: tokenKey.secret,
2190
+ ttlMs,
2191
+ now,
2192
+ scopesHash: scopeSpecsHash(tables),
2193
+ });
2194
+ return {
2195
+ // `parse_source_key` (rust/src/wasm/db.rs): anything but the reserved "daemon" is a room
2196
+ // source; the client-store convention is `room:` + the wire doc.
2197
+ sourceKey: `room:${doc}`,
2198
+ wsEndpoint,
2199
+ roomToken,
2200
+ exp: now + ttlMs,
2201
+ doc,
2202
+ tables,
2203
+ };
2204
+ } catch (e) {
2205
+ // Fail open — a lease is never blocked on the proof. Not cached (may be transient).
2206
+ warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
2207
+ return undefined;
2208
+ }
2209
+ };
2210
+
2211
+ // ---------------------------------------------------------------- the §4 lifecycle mint (I-iii)
2212
+ //
2213
+ // Gated on the OPT-IN `realtime.lifecycle` block: absent, this whole section is dead code and
2214
+ // every lease response is byte-identical to pre-lifecycle. Present, a labeled lease gains the
2215
+ // doorbell system lease and a ROOM-SERVED one the fence bundle (see {@link QueryLeaseLifecycle}).
2216
+ // FAIL-OPEN like the room-serve decision: a mint failure (e.g. a daemon that never ran
2217
+ // `enable_realtime_lifecycle`) warns once per query and the lease ships without the block.
2218
+
2219
+ const warnedLifecycle = new Set<string>();
2220
+ const warnLifecycleOnce = (queryName: string, reason: string): void => {
2221
+ if (warnedLifecycle.has(queryName)) return;
2222
+ warnedLifecycle.add(queryName);
2223
+ realtimeWarn(
2224
+ `query "${queryName}" is realtime-labeled with lifecycle configured, but its lifecycle ` +
2225
+ `system leases were not minted — ${reason}. The lease serves without the lifecycle block ` +
2226
+ `(correct, just no §4 upgrade/downgrade plane). This warning fires once per query.`,
2227
+ );
2228
+ };
2229
+
2230
+ /** THE SCOPE-KEY DECISION (§4.1): the doorbell scope IS the wire room doc — `"<profile>/<key>"`
2231
+ * via {@link mintRoomDoc}, the same computation `maybeRoomServe` runs (label args mapping →
2232
+ * `profile.key`) and the same key `locateRoom`/`/room-boot` address the room by. Occupancy
2233
+ * (I-iv writes the `_rindle_scope_sessions` rows) must be counted on EXACTLY the key the 1→2
2234
+ * transition provisions, and this is that key. Computed independently of the room-serve
2235
+ * decision on purpose: the doorbell rides every LABELED lease — an uncovered/unwired labeled
2236
+ * query still counts toward occupancy (its collaborators still want the upgrade). */
2237
+ const lifecycleScopeDoc = (input: QueryLeaseRequest<User>): string | undefined => {
2238
+ const label = queryRealtimeLabel(opts.queries?.[input.name]);
2239
+ if (label === undefined) return undefined; // unlabeled — no scope to count on
2240
+ const profile = roomProfiles.get(label.room);
2241
+ if (profile === undefined) return undefined; // unreachable: construction asserted it exists
2242
+ const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
2243
+ return mintRoomDoc(profile.name, profile.key(roomArgs));
2244
+ };
2245
+
2246
+ const maybeLifecycle = async (
2247
+ input: QueryLeaseRequest<User>,
2248
+ roomServed: boolean,
2249
+ subject: string | undefined,
2250
+ routingKey: string | undefined,
2251
+ ): Promise<QueryLeaseLifecycle | undefined> => {
2252
+ if (realtime?.lifecycle === undefined) return undefined; // the opt-in gate — mint NOTHING
2253
+ try {
2254
+ const doc = lifecycleScopeDoc(input);
2255
+ if (doc === undefined) return undefined;
2256
+ // Each system lease is an ordinary daemon materialization (the room-boot direct pattern),
2257
+ // carrying the SAME subject/routingKey as the primary lease so a routed deploy co-locates
2258
+ // the system streams on the follower the client's daemon session already lives on. The
2259
+ // daemon dedups by canonical query, so N clients' doorbells over one scope share ONE
2260
+ // materialization (each still minting its own leaseToken); the client-scoped fence ASTs
2261
+ // are per-client by construction.
2262
+ const mint = (ast: Ast) =>
2263
+ opts.daemon.materialize({
2264
+ ast,
2265
+ mode,
2266
+ subject,
2267
+ leaseTtlMs: opts.leaseTtlMs,
2268
+ metadata: routingKey !== undefined ? { routingKey } : undefined,
2269
+ });
2270
+ const lease = (table: string, out: MaterializeOutput, id: { scope?: string; doc?: string; clientId?: string }): QueryLeaseLifecycleLease => ({
2271
+ table,
2272
+ leaseToken: out.leaseToken,
2273
+ ...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
2274
+ ...(id.scope !== undefined ? { scope: id.scope } : {}),
2275
+ ...(id.doc !== undefined ? { doc: id.doc } : {}),
2276
+ ...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
2277
+ });
2278
+ const lifecycle: QueryLeaseLifecycle = {
2279
+ doorbell: lease(SCOPE_SESSIONS_TABLE, await mint(scopeSessionsAst(doc)), { scope: doc }),
2280
+ };
2281
+ // The fence bundle only where a room domain exists to fence (room-served leases): the
2282
+ // §4.2 watermark, the §7.1 daemon-carried ledger, and the §3.3 outcome rows.
2283
+ if (roomServed) {
2284
+ const clientId = input.clientId;
2285
+ lifecycle.fence = [
2286
+ lease(ROOM_WATERMARK_TABLE, await mint(roomWatermarkAst(doc)), { doc }),
2287
+ lease(ROOM_CLIENT_MUTATIONS_TABLE, await mint(docClientAst(ROOM_CLIENT_MUTATIONS_TABLE, doc, clientId)), { doc, clientId }),
2288
+ lease(ROOM_MUTATION_OUTCOMES_TABLE, await mint(docClientAst(ROOM_MUTATION_OUTCOMES_TABLE, doc, clientId)), { doc, clientId }),
2289
+ ];
2290
+ }
2291
+ return lifecycle;
2292
+ } catch (e) {
2293
+ warnLifecycleOnce(input.name, errMessage(e)); // fail open — a lease is never blocked
2294
+ return undefined;
2295
+ }
2296
+ };
2297
+
2298
+ // ------------------------------------------------------------ the §4.1 occupancy gate (I-iv)
2299
+ //
2300
+ // Runs on EVERY labeled lease under the opt-in `realtime.lifecycle` config (mint AND renewal —
2301
+ // both land on this same route), BEFORE the room-serve decision: (1) sweep + upsert the
2302
+ // caller's session row through the normal write path (the write is the doorbell — I-i's CDC
2303
+ // capture fans the row delta to every solo watcher's doorbell subscription), then (2) read the
2304
+ // occupancy count and return the D6 gate verdict `maybeRoomServe` is conditioned on. The upsert
2305
+ // deliberately precedes the count so the caller's own row is on disk when the verdict is
2306
+ // computed (its own presence rides the `+ 1`, and — more importantly — a concurrent second
2307
+ // client's read sees it). Ordering within the pair is otherwise value-neutral: the count
2308
+ // EXCLUDES the caller's clientId and adds the `+ 1` analytically.
2309
+ //
2310
+ // THE RENEWAL-vs-FRESH DECISION (grounded here because the task forces it): this server is
2311
+ // stateless and the lease request carries no "I am currently room-attached" field, so the gate
2312
+ // CANNOT distinguish a fresh mint from a live room's renewal. Instead of gating on the raw
2313
+ // count (which would suppress a momentarily-solo room's renewal and force the loud client-side
2314
+ // downgrade anomaly), the gate applies the §9.1 hysteresis DIRECTLY FROM THE LINGERING ROWS the
2315
+ // D4 sweep preserves: room-serve iff `liveOthers + self ≥ minSessions` OR some other session
2316
+ // expired within `graceMs`. A renewal is therefore never suppressed until the scope has been
2317
+ // solo SUSTAINED past the grace window — which is exactly Slice I-v's downgrade condition, read
2318
+ // from the same rows; I-v replaces that post-grace loud suppression with the fenced downgrade
2319
+ // dance, refining (not re-deciding) this verdict. A truly fresh solo scope (no other row, live
2320
+ // or lingering) is suppressed immediately — the D6 point.
2321
+ //
2322
+ // Timestamps are `Date.now()` server-side throughout (mint, sweep, count): occupancy tolerates
2323
+ // clock skew between api-server instances up to ~grace — a skewed `now` moves a session between
2324
+ // "live" and "in-grace", both of which hold the gate open; only skew past the grace+slack band
2325
+ // could mis-sweep, and the slack exists to keep that band clear.
2326
+ //
2327
+ // A request with NO `clientId` (a non-shipped client — the shipped one always sends it, see
2328
+ // `postLease`) upserts NO row and contributes NOTHING to occupancy, including to its own gate:
2329
+ // it room-serves only if the OTHER sessions alone reach `minSessions` (there is no session
2330
+ // identity to count it under, D7). It still gets its doorbell (`maybeLifecycle` is independent).
2331
+ //
2332
+ // FAIL-OPEN, like every lifecycle surface: an occupancy failure (e.g. a daemon that never ran
2333
+ // `enable_realtime_lifecycle`) warns once per query and returns `true` — the gate falls away
2334
+ // and the lease serves exactly as pre-I-iv. Suppressing on infrastructure failure would turn
2335
+ // realtime off fleet-wide from one missing table; never block, never suppress, on an error.
2336
+
2337
+ const warnedOccupancy = new Set<string>();
2338
+ interface LifecycleOccupancy {
2339
+ /** The D6 room-serve gate verdict — `false` ⇒ suppress the room block (solo/uncovered). */
2340
+ gateOpen: boolean;
2341
+ /** The I-v downgrade guard: a room plausibly hosts this scope (some other-session row lives
2342
+ * or lingers). Only a `!gateOpen && roomPlausible` reply drains — never a never-shared doc. */
2343
+ roomPlausible: boolean;
2344
+ /** The wire doc the scope maps to (`"<profile>/<key>"`); `undefined` when unlabeled / off. */
2345
+ doc: string | undefined;
2346
+ }
2347
+ const lifecycleOccupancy = async (input: QueryLeaseRequest<User>): Promise<LifecycleOccupancy> => {
2348
+ const lc = realtime?.lifecycle;
2349
+ if (lc === undefined) return { gateOpen: true, roomPlausible: false, doc: undefined }; // lifecycle off — the gate does not exist (inert-until-fed)
2350
+ const doc = lifecycleScopeDoc(input);
2351
+ if (doc === undefined) return { gateOpen: true, roomPlausible: false, doc: undefined }; // unlabeled — no scope to count on, nothing to gate
2352
+ try {
2353
+ const now = Date.now();
2354
+ const minSessions = lc.minSessions ?? DEFAULT_LIFECYCLE_MIN_SESSIONS;
2355
+ const graceMs = lc.graceMs ?? DEFAULT_LIFECYCLE_GRACE_MS;
2356
+ const sessionTtlMs = lc.sessionTtlMs ?? opts.leaseTtlMs ?? DEFAULT_SESSION_TTL_MS;
2357
+ const clientId = input.clientId;
2358
+ // (1) sweep + upsert, ONE write txn (D4: the sweep shares the upsert's transaction — no
2359
+ // separate maintenance pass, and the linger bound holds atomically with the refresh).
2360
+ const statements: SqlStatement[] = [
2361
+ { sql: SESSION_SWEEP_SQL, params: [doc, now - (graceMs + SESSION_SWEEP_SLACK_MS)] },
2362
+ ];
2363
+ if (clientId !== undefined) {
2364
+ statements.push({ sql: SESSION_UPSERT_SQL, params: [doc, clientId, now + sessionTtlMs] });
2365
+ }
2366
+ await opts.daemon.executeSqlTxn({ statements });
2367
+ // (2) the count — read-your-writes via `consistency: "strong"` (see the section note above).
2368
+ const read = await opts.daemon.executeSqlRead({
2369
+ sql: clientId !== undefined ? SESSION_COUNT_OTHERS_SQL : SESSION_COUNT_SQL,
2370
+ params:
2371
+ clientId !== undefined
2372
+ ? [now, now, now - graceMs, doc, clientId]
2373
+ : [now, now, now - graceMs, doc],
2374
+ consistency: "strong",
2375
+ });
2376
+ const cells = read.rows[0] ?? [];
2377
+ const liveOthers = Number(cells[0] ?? 0); // SUM over zero rows is NULL — coerce
2378
+ const graceOthers = Number(cells[1] ?? 0);
2379
+ const totalOthers = Number(cells[2] ?? 0); // ALL other rows (any expiry, pre-sweep)
2380
+ const self = clientId !== undefined ? 1 : 0;
2381
+ return {
2382
+ gateOpen: liveOthers + self >= minSessions || graceOthers > 0,
2383
+ // Room plausibly exists ⇒ this scope was shared (a room was provisioned on the 1→2). A
2384
+ // never-shared solo doc has NO other row and must never drain (no wasted room boot).
2385
+ roomPlausible: totalOthers > 0,
2386
+ doc,
2387
+ };
2388
+ } catch (e) {
2389
+ if (!warnedOccupancy.has(input.name)) {
2390
+ warnedOccupancy.add(input.name);
2391
+ realtimeWarn(
2392
+ `query "${input.name}" is realtime-labeled with lifecycle configured, but the §4.1 ` +
2393
+ `occupancy step failed — ${errMessage(e)}. The occupancy gate fail-opens (the lease ` +
2394
+ `serves exactly as pre-I-iv; no session row was counted). This warning fires once per query.`,
2395
+ );
2396
+ }
2397
+ return { gateOpen: true, roomPlausible: false, doc };
2398
+ }
2399
+ };
2400
+
2401
+ // ------------------------------------------------------------ the §4.2 downgrade drain (I-v)
2402
+ //
2403
+ // When the occupancy gate closes for a scope a room plausibly hosted, drain that room to a
2404
+ // COMMITTED flush seq and hand it back as the fence. `drainRoom` (deployment-wired to the room
2405
+ // shell / DO `/drain`) is idempotent (concurrent api-server instances may both call it) and
2406
+ // fails OPEN — a downgrade must never block a lease, so an unconfigured or throwing hook simply
2407
+ // omits the fence (warn-once) and the client falls to its loud legacy downgrade path.
2408
+ const warnedDrain = new Set<string>();
2409
+ const warnDrainOnce = (queryName: string, reason: string): void => {
2410
+ if (warnedDrain.has(queryName)) return;
2411
+ warnedDrain.add(queryName);
2412
+ realtimeWarn(
2413
+ `query "${queryName}" downgraded (occupancy gate closed) but no §4.2 fence was attached — ` +
2414
+ `${reason}. The lease ships without the fence; a room-attached client falls back to its ` +
2415
+ `loud legacy downgrade (correct, just not graceful). This warning fires once per query.`,
2416
+ );
2417
+ };
2418
+ const maybeDrainRoom = async (queryName: string, doc: string): Promise<QueryLeaseRealtimeFence | undefined> => {
2419
+ const drainRoom = realtime?.lifecycle?.drainRoom;
2420
+ if (drainRoom === undefined) {
2421
+ warnDrainOnce(queryName, "realtime.lifecycle.drainRoom is not configured");
2422
+ return undefined;
2423
+ }
2424
+ try {
2425
+ const { finalFlushSeq } = await drainRoom(doc);
2426
+ return { sourceKey: `room:${doc}`, doc, finalFlushSeq };
2427
+ } catch (e) {
2428
+ warnDrainOnce(queryName, `drainRoom threw — ${errMessage(e)}`);
2429
+ return undefined;
2430
+ }
2431
+ };
2432
+
1562
2433
  const createQueryLease = async (input: QueryLeaseRequest<User>): Promise<QueryLeaseResponse> => {
1563
2434
  const context: ApiContext<User> = { user: input.user, request: input.request };
1564
2435
  await assertAuthorized(opts.authorizeQuery, {
@@ -1586,7 +2457,33 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1586
2457
  // `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
1587
2458
  metadata: routingKey !== undefined ? { routingKey } : undefined,
1588
2459
  });
1589
- return queryLeaseResponse(out);
2460
+ const res = queryLeaseResponse(out);
2461
+ // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
2462
+ // closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
2463
+ // indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
2464
+ // below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
2465
+ const occ = await lifecycleOccupancy(input);
2466
+ // G-iv-b: a covered labeled query ADDITIONALLY gains the realtime block. The daemon lease
2467
+ // above is unconditional (and its fields untouched) — room-serving only ever adds a field,
2468
+ // so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
2469
+ const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
2470
+ if (rt !== undefined) res.realtime = rt;
2471
+ // I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
2472
+ // fence back so a room-attached client runs the GRACEFUL downgrade instead of the loud legacy
2473
+ // anomaly. A never-shared solo doc (`!roomPlausible`) never drains (no wasted room boot); a
2474
+ // daemon-attached client that receives a stray fence ignores it (its resolver reads only the
2475
+ // daemon fields). `drainRoom` absent/throwing ⇒ no fence (fail-open, warn-once).
2476
+ if (!occ.gateOpen && occ.roomPlausible && occ.doc !== undefined) {
2477
+ const fence = await maybeDrainRoom(input.name, occ.doc);
2478
+ if (fence !== undefined) res.realtimeFence = fence;
2479
+ }
2480
+ // I-iii: under the opt-in `realtime.lifecycle` config a LABELED lease additionally gains the
2481
+ // §4 system-stream block (doorbell always; the fence bundle iff room-served OR downgrade-fenced
2482
+ // — a downgrading client needs the watermark/ledger/outcome streams to run the ghost drop).
2483
+ // Same additive discipline as the realtime block: absent config ⇒ byte-identical response.
2484
+ const lc = await maybeLifecycle(input, rt !== undefined || res.realtimeFence !== undefined, subject, routingKey);
2485
+ if (lc !== undefined) res.lifecycle = lc;
2486
+ return res;
1590
2487
  };
1591
2488
 
1592
2489
  const readQuery = async (input: QueryReadRequest<User>): Promise<QueryReadResponse> => {
@@ -1628,23 +2525,80 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1628
2525
  } catch (err) {
1629
2526
  return reject(backend, input.envelope, errMessage(err));
1630
2527
  }
1631
- // Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
1632
- // rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
2528
+ const mctx: MutationContext<User> = {
2529
+ user: input.user,
2530
+ envelope: input.envelope,
2531
+ daemon: opts.daemon,
2532
+ request: input.request,
2533
+ };
2534
+
2535
+ // SCOPED mutator (WORK-OUTSIDE-TX): the author controls the tx boundary via `scope.transact`,
2536
+ // running server-only code before/after it. The `lmid`-always-advances invariant is OURS, not
2537
+ // the author's — we seal the response from the scope's recorded state, so an early return, a
2538
+ // never-called transact, or a swallowed rejection can't wedge the client's pending queue.
2539
+ if (isScoped<User>(mutator)) {
2540
+ const scope = new MutationScopeImpl(backend, input.envelope, renderIndex);
2541
+ // A throw that reaches `settle` AFTER the outcome is already sealed (post-commit effect, or a
2542
+ // compensation handler after a business rejection) can't change the response — but it must not
2543
+ // vanish. Route it to the app's hook, else log so a failed refund is never fully silent.
2544
+ const reportSealed = (err: unknown, phase: "committed" | "rejected"): void => {
2545
+ if (opts.onScopeError) opts.onScopeError(err, { phase, envelope: input.envelope });
2546
+ else console.error(`[rindle api-server] scoped mutator ${input.envelope.name}: post-${phase} code threw (outcome already sealed):`, err);
2547
+ };
2548
+ // Derive the response from the scope's OUTCOME (not the body's return), so control flow in the
2549
+ // author's function can't skip the lmid advance. `caught` distinguishes "the body threw"
2550
+ // (present, even if the thrown value was `undefined`) from "it returned cleanly".
2551
+ const settle = async (caught?: { err: unknown }): Promise<PushMutationResponse> => {
2552
+ // Seal from the REAL outcome even if the author forgot to `await` transact: draining its
2553
+ // in-flight promise here records the outcome/infra before we read it (else a phantom no-op
2554
+ // ships while the real write commits out-of-band). Already-resolved when it WAS awaited.
2555
+ if (scope.pending) await scope.pending;
2556
+ // Infra always wins: the backend threw, the commit state is unknown — never advance lmid.
2557
+ // Keyed on the latched BOOLEAN, so a driver that rejects with a falsy value is still infra.
2558
+ if (scope.infraLatched) throw scope.infra;
2559
+ // transact resolved (committed OR business-rejected): seal from its recorded outcome. A
2560
+ // post-commit / post-reject-compensation throw can't change the sealed outcome (its effects
2561
+ // can't roll the tx back, and lmid already advanced §2.4). Rethrowing the MutationRejected is
2562
+ // the sanctioned "compensated, stay rejected" signal — expected, not surfaced. Any OTHER throw
2563
+ // (a FAILED refund, a post-commit effect) must not vanish — surface it.
2564
+ if (scope.outcome) {
2565
+ if (caught && !(caught.err instanceof MutationRejected)) {
2566
+ reportSealed(caught.err, scope.outcome.accepted ? "committed" : "rejected");
2567
+ }
2568
+ return outcomeToResponse(scope.outcome);
2569
+ }
2570
+ // Never transacted:
2571
+ if (caught) {
2572
+ // A throw before/around transact. A BackendError is the author signaling INFRA (retry);
2573
+ // any other throw is a BUSINESS rejection — advance lmid alone so the prediction snaps back.
2574
+ if (caught.err instanceof BackendError) throw caught.err.driverError;
2575
+ return reject(backend, input.envelope, errMessage(caught.err));
2576
+ }
2577
+ // Clean return with no transact — an accepted no-op that STILL advances lmid (the client
2578
+ // predicted a write; its pending entry must resolve).
2579
+ return outcomeToResponse(
2580
+ await backend.runMutation({ envelope: input.envelope, render: renderIndex, run: async () => {} }),
2581
+ );
2582
+ };
2583
+ try {
2584
+ await (mutator as unknown as ScopedMutator<User, unknown>)(scope, input.envelope.args as never, mctx);
2585
+ } catch (err) {
2586
+ return settle({ err });
2587
+ }
2588
+ return settle();
2589
+ }
2590
+
2591
+ // Run the (tx-form) mutator INSIDE the backend's transaction. A throw from the mutator body is a
2592
+ // business rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1633
2593
  const outcome = await backend.runMutation({
1634
2594
  envelope: input.envelope,
1635
2595
  render: renderIndex,
1636
2596
  run: async (tx) => {
1637
- const result = await mutator(tx, input.envelope.args as never, {
1638
- user: input.user,
1639
- envelope: input.envelope,
1640
- daemon: opts.daemon,
1641
- request: input.request,
1642
- });
2597
+ const result = await mutator(tx, input.envelope.args as never, mctx);
1643
2598
  applyResultToTx(result, tx);
1644
2599
  },
1645
2600
  });
1646
- if (outcome.accepted) return { accepted: true, rejected: false, output: outcome.output };
1647
- return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
2601
+ return outcomeToResponse(outcome);
1648
2602
  };
1649
2603
 
1650
2604
  const pushMutations = async (input: PushMutationsRequest<User>): Promise<PushMutationResponse[]> => {
@@ -1702,10 +2656,60 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1702
2656
  }
1703
2657
  };
1704
2658
 
2659
+ // The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
2660
+ // `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
2661
+ // the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
2662
+ // lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
2663
+ // ignores: it answers "is this labeled query coverable", the deployable-config question.
2664
+ const validateRealtime = async (vopts?: {
2665
+ exemplars?: Partial<Record<string, readonly unknown[]>>;
2666
+ strict?: boolean;
2667
+ }): Promise<ValidateRealtimeReport> => {
2668
+ const context: ApiContext<User> = { user: opts.pinUser as User, request: undefined };
2669
+ const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
2670
+ const verdicts: ValidateRealtimeVerdict[] = [];
2671
+ for (const [name, q] of Object.entries(opts.queries ?? {})) {
2672
+ const label = queryRealtimeLabel(q);
2673
+ if (label === undefined) continue;
2674
+ const profile = roomProfiles.get(label.room);
2675
+ if (profile === undefined) continue; // unreachable: construction asserted it exists
2676
+ for (const args of vopts?.exemplars?.[name] ?? [null]) {
2677
+ const verdict: ValidateRealtimeVerdict = { query: name, profile: profile.name, args, covered: false };
2678
+ try {
2679
+ const ast = await resolveAst(name, args, context);
2680
+ const roomArgs = label.args !== undefined ? label.args(args) : args;
2681
+ const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
2682
+ assertUnwindowedFootprint(footprintAst, profile.name);
2683
+ if (astHasAggregate(ast)) {
2684
+ verdict.reasons = [AGGREGATE_REFUSAL];
2685
+ } else if (coverQuery === undefined) {
2686
+ verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
2687
+ } else {
2688
+ const out = await coverQuery({ footprint: footprintAst, query: ast });
2689
+ verdict.covered = out.covered;
2690
+ if (!out.covered) verdict.reasons = out.reasons ?? ["not provably covered"];
2691
+ }
2692
+ } catch (e) {
2693
+ verdict.reasons = [errMessage(e)];
2694
+ }
2695
+ verdicts.push(verdict);
2696
+ }
2697
+ }
2698
+ const uncovered = verdicts.filter((v) => !v.covered);
2699
+ if (vopts?.strict && uncovered.length > 0) {
2700
+ throw new Error(
2701
+ `validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
2702
+ uncovered
2703
+ .map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
2704
+ .join(" | "),
2705
+ );
2706
+ }
2707
+ return { verdicts, uncovered };
2708
+ };
2709
+
1705
2710
  // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
1706
2711
  // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
1707
2712
  // `authorizeRoom` (trio only). Hosting an authority is never a default.
1708
- const realtime = opts.realtime;
1709
2713
  const roomAuthorizer: Authorizer<ApiContext<User>> | undefined = realtime
1710
2714
  ? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
1711
2715
  : opts.authorizeRoom;
@@ -1716,6 +2720,40 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1716
2720
  await assertAuthorized(roomAuthorizer, context);
1717
2721
  };
1718
2722
 
2723
+ // §2.1 room-key routing: a "<profile>/<key>" doc resolves through its NAMED profile — with the
2724
+ // boot-time unwindowed backstop (§2.3), which covers footprints that weren't statically
2725
+ // resolvable at construction AND key-dependent branches that window only some docs. Anything
2726
+ // else falls through to the legacy single-profile alias BYTE-IDENTICALLY (the anonymous
2727
+ // profile, bare-key form). A named profile wins over a legacy doc that merely contains "/".
2728
+ // Returns the profile's context set beside the AST (H-iv-b: the scope-spec compilation needs
2729
+ // the §2.2 owned/followed split; the legacy anonymous profile has no declaration — empty set).
2730
+ const resolveRoomFootprint = async (
2731
+ rt: RindleRealtimeOptions<User>,
2732
+ doc: string,
2733
+ context: ApiContext<User>,
2734
+ ): Promise<{ ast: Ast; contextTables: ReadonlySet<string> }> => {
2735
+ const split = splitRoomDoc(doc);
2736
+ if (split !== undefined) {
2737
+ const profile = roomProfiles.get(split.profile);
2738
+ if (profile !== undefined) {
2739
+ const ast = queryResultToAst(await profile.footprint(split.key, context));
2740
+ assertUnwindowedFootprint(ast, profile.name);
2741
+ return { ast, contextTables: profile.context };
2742
+ }
2743
+ }
2744
+ if (rt.resolveFootprint) {
2745
+ return {
2746
+ ast: queryResultToAst(await rt.resolveFootprint(doc, context)),
2747
+ contextTables: new Set<string>(),
2748
+ };
2749
+ }
2750
+ throw new RindleApiError(
2751
+ "not-found",
2752
+ `no room profile matches doc "${doc}" — named profiles are addressed as "<profile>/<key>"`,
2753
+ 404,
2754
+ );
2755
+ };
2756
+
1719
2757
  // The store's verdict rides specific statuses + body shapes (fence / conflict /
1720
2758
  // identity) the room decodes — pass a daemon HTTP error through VERBATIM.
1721
2759
  const daemonVerdict = (e: unknown): RoomHostResponse => {
@@ -1736,6 +2774,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1736
2774
  createQueryLease,
1737
2775
  readQuery,
1738
2776
  assertPins,
2777
+ validateRealtime,
1739
2778
  pushMutation,
1740
2779
  pushMutations,
1741
2780
  handleApplyRowChangeTxnJson: async (body, context) => {
@@ -1765,6 +2804,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1765
2804
  handleRoomLmidsJson: async (body, context) => {
1766
2805
  await roomGate(context);
1767
2806
  const msg = parseObject(body, "room-lmids request");
2807
+ const doc = parseString(msg.doc, "doc");
1768
2808
  if (!Array.isArray(msg.clients) || msg.clients.some((c) => typeof c !== "string")) {
1769
2809
  throw new RindleApiError("bad-request", "clients must be an array of strings", 400);
1770
2810
  }
@@ -1773,7 +2813,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1773
2813
  throw new Error("the configured daemon client does not implement roomLmids");
1774
2814
  }
1775
2815
  try {
1776
- return { status: 200, body: await lmids({ clients: msg.clients as string[] }) };
2816
+ return { status: 200, body: await lmids({ doc, clients: msg.clients as string[] }) };
1777
2817
  } catch (e) {
1778
2818
  return daemonVerdict(e);
1779
2819
  }
@@ -1786,7 +2826,7 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1786
2826
  const msg = parseObject(body, "room-boot request");
1787
2827
  const doc = parseString(msg.doc, "doc");
1788
2828
  if (msg.instance !== undefined) parseString(msg.instance, "instance"); // diagnostic identity only
1789
- const ast = queryResultToAst(await realtime.resolveFootprint(doc, context));
2829
+ const { ast, contextTables } = await resolveRoomFootprint(realtime, doc, context);
1790
2830
  const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
1791
2831
  if (!claim) {
1792
2832
  throw new Error("the configured daemon client does not implement claimRoomEpoch");
@@ -1814,6 +2854,9 @@ export function createRindleApiServer<User = unknown>(opts: RindleApiServerOptio
1814
2854
  const res: RoomBootResponse = {
1815
2855
  epoch,
1816
2856
  upstreamLeaseToken: lease.leaseToken,
2857
+ // H-iv-b: the §3.3 commit-gate scope specs, for named-profile AND legacy docs alike
2858
+ // (the footprint AST is resolved either way; legacy has an empty context set).
2859
+ scopes: compileRoomScopeSpecs(ast, contextTables),
1817
2860
  flush: {
1818
2861
  urls: {
1819
2862
  apply: routes.applyRowChangeTxn,
@@ -2034,6 +3077,145 @@ function errMessage(reason: unknown): string {
2034
3077
  return String((reason as Error)?.message ?? reason);
2035
3078
  }
2036
3079
 
3080
+ // --------------------------------------------------------- lifecycle system leases (Slice I-iii)
3081
+
3082
+ // The four §4 lifecycle system tables, mirrored VERBATIM from the daemon DDL — the source of
3083
+ // truth is `rust/rindle-replica/src/mutations.rs` (`realtime_lifecycle_ddl()` + the room-ledger
3084
+ // DDL in `enable_client_mutations`); duplicated here like `DEFAULT_ROUTES` is client-side so this
3085
+ // package needs no engine import. `Db::enable_realtime_lifecycle` REGISTERS all four, so a
3086
+ // hand-built AST over them materializes and resolves `hello` like any base table (the room-boot
3087
+ // direct-materialize pattern).
3088
+ const SCOPE_SESSIONS_TABLE = "_rindle_scope_sessions";
3089
+ const ROOM_WATERMARK_TABLE = "_rindle_room_watermark";
3090
+ const ROOM_CLIENT_MUTATIONS_TABLE = "_rindle_room_client_mutations";
3091
+ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
3092
+
3093
+ // --------------------------------------------------------- occupancy counting (Slice I-iv, §4.1)
3094
+ //
3095
+ // The occupancy step rides the NORMAL surfaces end to end: the session upsert + lazy sweep are one
3096
+ // `executeSqlTxn` (a plain write txn — CDC-captured since I-i, so the row landing IS the doorbell
3097
+ // delta fanning to every subscribed solo client; no clientID/mid — a system write must never
3098
+ // advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
3099
+ // refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
3100
+ // the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
3101
+ // "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
3102
+ // upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
3103
+ // on the daemon interface and far heavier than this two-round-trip pair needs).
3104
+
3105
+ /** Default {@link RindleRealtimeLifecycleOptions.minSessions} — the §4.1 1→2 trigger. */
3106
+ const DEFAULT_LIFECYCLE_MIN_SESSIONS = 2;
3107
+ /** Default {@link RindleRealtimeLifecycleOptions.graceMs} — the §9.1 hysteresis window. */
3108
+ const DEFAULT_LIFECYCLE_GRACE_MS = 120_000;
3109
+ /** Default {@link RindleRealtimeLifecycleOptions.sessionTtlMs} fallback when no `leaseTtlMs` is
3110
+ * configured either — 5 minutes, the {@link DEFAULT_ROOM_TOKEN_TTL_MS} cadence (see the field doc). */
3111
+ const DEFAULT_SESSION_TTL_MS = 5 * 60_000;
3112
+ /** Sweep slack past the grace window (D4): rows are deleted only once expired for MORE than
3113
+ * `graceMs + this` — the linger I-v's downgrade decision reads must comfortably outlive the
3114
+ * grace comparison itself under clock skew between api-server instances (occupancy tolerates
3115
+ * skew ≤ grace; the slack keeps the boundary case out of the deletable band). */
3116
+ const SESSION_SWEEP_SLACK_MS = 60_000;
3117
+
3118
+ /** D7 upsert: one row per (scope, clientId) — `(scope, client_id)` is the table's PRIMARY KEY
3119
+ * (`realtime_lifecycle_ddl()`), so a renewal refreshes `expires_at` in place. */
3120
+ const SESSION_UPSERT_SQL =
3121
+ `INSERT INTO ${SCOPE_SESSIONS_TABLE} (scope, client_id, expires_at) VALUES (?, ?, ?) ` +
3122
+ `ON CONFLICT(scope, client_id) DO UPDATE SET expires_at = excluded.expires_at`;
3123
+ /** The D4 lazy sweep, in the SAME txn as the upsert: age out THIS scope's long-expired rows.
3124
+ * Param 2 is `now − (graceMs + SESSION_SWEEP_SLACK_MS)` — never tighter (the linger contract). */
3125
+ const SESSION_SWEEP_SQL = `DELETE FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ? AND expires_at < ?`;
3126
+ /** The occupancy read, one SELECT: cell 0 = DISTINCT unexpired sessions (`expires_at > now`;
3127
+ * distinct by construction — `(scope, client_id)` is the PK), cell 1 = sessions expired WITHIN
3128
+ * the grace window (`now − graceMs < expires_at ≤ now`) — the upward hysteresis input, cell 2 =
3129
+ * ALL matching rows regardless of expiry (the I-v "room plausibly exists" signal: a scope with
3130
+ * ANY other-session row — live OR still lingering pre-sweep — was shared, so a room was
3131
+ * provisioned; a never-shared solo doc has none and must never drain). Params:
3132
+ * `[now, now, now − graceMs, scope]`. */
3133
+ const SESSION_COUNT_SQL =
3134
+ `SELECT SUM(CASE WHEN expires_at > ? THEN 1 ELSE 0 END), ` +
3135
+ `SUM(CASE WHEN expires_at <= ? AND expires_at > ? THEN 1 ELSE 0 END), ` +
3136
+ `COUNT(*) ` +
3137
+ `FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ?`;
3138
+ /** {@link SESSION_COUNT_SQL} excluding the CALLER's own row (D6 counts *other* sessions; the
3139
+ * caller contributes itself as the `+ 1`). One extra trailing param: the caller's clientId. */
3140
+ const SESSION_COUNT_OTHERS_SQL = `${SESSION_COUNT_SQL} AND client_id <> ?`;
3141
+
3142
+ /** `col = <string literal>` — the only predicate shape the lifecycle ASTs need. */
3143
+ function colEq(name: string, value: string): Condition {
3144
+ return { type: "simple", op: "=", left: { type: "column", name }, right: { type: "literal", value } };
3145
+ }
3146
+
3147
+ /** The doorbell AST (§4.1): every unexpired row under the scope is one live session; the row
3148
+ * delta arriving through a solo client's daemon subscription IS the 1→2 upgrade signal. The
3149
+ * expiry filter is deliberately NOT in the predicate — `expires_at > now()` would freeze `now`
3150
+ * at mint time; liveness is the READER's judgment (I-iv), the stream just carries the rows. */
3151
+ function scopeSessionsAst(scope: string): Ast {
3152
+ return { table: SCOPE_SESSIONS_TABLE, where: colEq("scope", scope) };
3153
+ }
3154
+
3155
+ /** The §4.2 fence AST: the doc's monotone `flush_seq` row. */
3156
+ function roomWatermarkAst(doc: string): Ast {
3157
+ return { table: ROOM_WATERMARK_TABLE, where: colEq("doc", doc) };
3158
+ }
3159
+
3160
+ /** The §7.1 ledger / §3.3 outcome ASTs share one shape: doc-scoped, and ADDITIONALLY
3161
+ * client-scoped when the lease request carried the browser's stable `clientId` (the same id the
3162
+ * mutation envelopes stamp, so it is exactly the ledger/outcome `client_id`). Without it the
3163
+ * predicate stays doc-only and the client filters to its own rows (defense in depth either
3164
+ * way — the client always filters). */
3165
+ function docClientAst(table: string, doc: string, clientId: string | undefined): Ast {
3166
+ const docCond = colEq("doc", doc);
3167
+ return {
3168
+ table,
3169
+ where: clientId === undefined ? docCond : { type: "and", conditions: [docCond, colEq("client_id", clientId)] },
3170
+ };
3171
+ }
3172
+
3173
+ // --------------------------------------------------------- room-serve helpers (G-iv-b)
3174
+
3175
+ /** Default room lease token TTL: short (minutes) per RINDLE-REALTIME §4.1 — renewal is a fresh
3176
+ * lease through the api-server, never an extension of this token. */
3177
+ const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
3178
+
3179
+ /** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
3180
+ * independent resolves stringify identically (the verdict-cache key). */
3181
+ function stableStringify(v: unknown): string {
3182
+ return JSON.stringify(v, (_key, value: unknown) => {
3183
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
3184
+ const rec = value as Record<string, unknown>;
3185
+ const sorted: Record<string, unknown> = {};
3186
+ for (const k of Object.keys(rec).sort()) sorted[k] = rec[k];
3187
+ return sorted;
3188
+ }
3189
+ return value;
3190
+ });
3191
+ }
3192
+
3193
+ /** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
3194
+ * `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
3195
+ * overlay (AGGREGATE-SYNC) is computed against the DAEMON's normalized stream and stays
3196
+ * daemon-gated until post-G. (`groupBy`/`having` only occur alongside `aggregate`, so testing
3197
+ * `aggregate` covers them; `having` is still walked for nested EXISTS aggregates.) */
3198
+ function astHasAggregate(ast: Ast): boolean {
3199
+ if (ast.aggregate !== undefined) return true;
3200
+ for (const rel of ast.related ?? []) {
3201
+ if (astHasAggregate(rel.subquery)) return true;
3202
+ }
3203
+ return conditionHasAggregate(ast.where) || conditionHasAggregate(ast.having);
3204
+ }
3205
+
3206
+ function conditionHasAggregate(cond: Condition | undefined): boolean {
3207
+ if (cond === undefined) return false;
3208
+ switch (cond.type) {
3209
+ case "simple":
3210
+ return false;
3211
+ case "and":
3212
+ case "or":
3213
+ return cond.conditions.some(conditionHasAggregate);
3214
+ case "correlatedSubquery":
3215
+ return astHasAggregate(cond.related.subquery);
3216
+ }
3217
+ }
3218
+
2037
3219
  async function reject(
2038
3220
  backend: MutationBackend,
2039
3221
  envelope: MutationEnvelope,
@@ -2043,6 +3225,15 @@ async function reject(
2043
3225
  return { accepted: false, rejected: true, reason, output };
2044
3226
  }
2045
3227
 
3228
+ /** The single place a {@link MutationOutcome} becomes the wire {@link PushMutationResponse} — shared
3229
+ * by the tx-form path and every scoped-mutator seal branch so the accepted/rejected shape can never
3230
+ * drift between them. */
3231
+ function outcomeToResponse(outcome: MutationOutcome): PushMutationResponse {
3232
+ return outcome.accepted
3233
+ ? { accepted: true, rejected: false, output: outcome.output }
3234
+ : { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
3235
+ }
3236
+
2046
3237
  function parseObject(value: unknown, label: string): Record<string, unknown> {
2047
3238
  if (!value || typeof value !== "object" || Array.isArray(value)) {
2048
3239
  throw new RindleApiError("bad-request", `invalid ${label}`, 400);