@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/dist/index.js CHANGED
@@ -1,10 +1,24 @@
1
- import { driveMutationAsync, insertCell, insertPlan, isoTx, toCell } from "@rindle/client";
1
+ import { driveMutationAsync, insertCell, insertPlan, isGeneratorMutator, isoTx, toCell } from "@rindle/client";
2
2
  import { DaemonHttpError } from "@rindle/daemon-client";
3
3
  import { compile as compileQueryAst } from "@rindle/query-compiler";
4
+ import { assertLabeledProfilesExist, assertUnwindowedFootprint, attachRealtimeLabel, compileRoomProfiles, compileRoomScopeSpecs, compileRoomTableSpecs, mintRoomDoc, queryRealtimeLabel, queryResultToAst, splitRoomDoc, } from "./rooms.js";
5
+ let roomTokenModule;
6
+ async function loadRoomTokenModule() {
7
+ if (roomTokenModule === undefined) {
8
+ const m = await import("@rindle/room/token");
9
+ roomTokenModule = { mintRoomToken: m.mintRoomToken, scopeSpecsHash: m.scopeSpecsHash };
10
+ }
11
+ return roomTokenModule;
12
+ }
4
13
  // Re-export the shared (generator) mutator seam so an app builds its server mutators from ONE import:
5
14
  // co-locate each body with its arg schema (`shared`), bulk-drive the registry ({@link sharedApiMutators}),
6
15
  // keeping only server-only authority as explicit overrides (see MUTATORS-ISOMORPHIC).
7
16
  export { isoTx, shared } from "@rindle/client";
17
+ // The room-profile declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a). The
18
+ // compiled-profile shapes stay internal to `./rooms.ts` — G-iv-b consumes them in-package.
19
+ // `RoomTableSpec` (G-iv-b) is public: it rides the lease wire (`QueryLeaseResponse.realtime`).
20
+ // `RoomScopeSpec` (H-iv-b) is public: it rides the boot wire (`RoomBootResponse.scopes`).
21
+ export { queryRealtimeLabel, queryResultToAst } from "./rooms.js";
8
22
  export const DEFAULT_RINDLE_API_ROUTES = {
9
23
  query: "/api/rindle/query",
10
24
  read: "/api/rindle/read",
@@ -17,6 +31,58 @@ export const DEFAULT_RINDLE_API_ROUTES = {
17
31
  // The DO shell's cold-boot callback (§10.1) — active only when `realtime` is configured.
18
32
  roomBoot: "/api/rindle/room-boot",
19
33
  };
34
+ // --------------------------------------------------------------------------- scoped (outside-tx) mutators
35
+ //
36
+ // The tx-form {@link ApiMutator} above runs ENTIRELY inside the transaction. A SCOPED mutator
37
+ // (WORK-OUTSIDE-TX) instead controls the boundary itself: it receives a {@link MutationScope}, runs
38
+ // server-only code BEFORE opening the one atomic transaction (`scope.transact`), and MAY run code
39
+ // AFTER it commits. The outside-tx code is server-only by nature (the client's optimistic prediction
40
+ // can't call Stripe), so it lives HERE, never in the isomorphic body — the shared generator stays
41
+ // pure and identical on both tiers; server-computed values flow into it through `ctx`, exactly like
42
+ // `ctx.user` (undefined/predicted on the client, authoritative here).
43
+ /** Thrown by {@link MutationScope.transact} when the transacted body BUSINESS-rejects: the data
44
+ * rolled back and `lmid` advanced alone (§2.4). Catch it to COMPENSATE an outside-tx side effect
45
+ * (refund the charge), then rethrow or return — the mutation's protocol outcome is already sealed
46
+ * as rejected, so a post-reject throw can't change it. A DB/infra failure is NOT this — it
47
+ * propagates as the raw driver error (the client retries; `lmid` did not advance). */
48
+ export class MutationRejected extends Error {
49
+ reason;
50
+ constructor(reason) {
51
+ super(reason);
52
+ this.name = "MutationRejected";
53
+ this.reason = reason;
54
+ }
55
+ }
56
+ /** Mark a mutator as SCOPED so the api-server gives it a {@link MutationScope} (author-controlled tx
57
+ * boundary via `scope.transact`) rather than running its whole body inside the transaction. Register
58
+ * it alongside the tx-form mutators — it wins by key like any override:
59
+ *
60
+ * ```ts
61
+ * mutators: defineApiMutators({
62
+ * ...sharedApiMutators(sharedMutators, sharedCtx), // tx-form (common case)
63
+ * createOrder: scoped(async (scope, raw, ctx) => { // needs outside-tx work
64
+ * const args = createOrder.args.parse(raw);
65
+ * const chargeId = await stripe.charge(args.amount, { idempotencyKey: ctx.envelope.mid }); // outside tx
66
+ * try {
67
+ * await scope.transact(createOrder, args, { ...sharedCtx(ctx), chargeId }); // inside tx
68
+ * } catch (e) {
69
+ * await stripe.refund(chargeId); // compensate — the write rejected
70
+ * throw e;
71
+ * }
72
+ * await sendReceipt(ctx.user); // after commit
73
+ * }),
74
+ * }),
75
+ * ```
76
+ */
77
+ export function scoped(fn) {
78
+ // The brand carries the scoped runtime shape; typing the RETURN as the (branded) tx-form keeps it
79
+ // assignable into `mutators` WITHOUT unioning that record — the harness routes on the brand and
80
+ // never calls it as a tx-form mutator, so the cast is sound.
81
+ return Object.assign(fn, { __rindleScoped: true });
82
+ }
83
+ function isScoped(m) {
84
+ return m.__rindleScoped === true;
85
+ }
20
86
  // The DEFAULT flush credential: `rfc1.<b64url payload>.<b64url hmac-sha256>`, payload
21
87
  // `{v:1, doc, epoch, iat}`. Deliberately EPOCH-bound, not time-bound: the credential's lifecycle
22
88
  // IS the placement fence (§8.3 — a superseded epoch's flushes 409 at the store no matter what
@@ -182,6 +248,14 @@ export class SplitDaemonClient {
182
248
  return Promise.reject(new Error("the write master lacks roomLmids"));
183
249
  return lmids(input);
184
250
  }
251
+ // Pure computation, but routed to the MASTER like the rest of the room ops: the master is a
252
+ // real rindled that hosts `/cover-check`; the read router may not proxy it.
253
+ coverQuery(input) {
254
+ const cover = this.writes.coverQuery?.bind(this.writes);
255
+ if (!cover)
256
+ return Promise.reject(new Error("the write master lacks coverQuery"));
257
+ return cover(input);
258
+ }
185
259
  migrate(input) {
186
260
  return this.writes.migrate(input);
187
261
  }
@@ -851,7 +925,11 @@ export function registerQueries(queries) {
851
925
  if (Object.prototype.hasOwnProperty.call(out, query.queryName)) {
852
926
  throw new Error(`registerQueries: duplicate query name "${query.queryName}"`);
853
927
  }
854
- out[query.queryName] = (ctx, args) => query.resolve(args, ctx);
928
+ const wrapped = (ctx, args) => query.resolve(args, ctx);
929
+ // The §2.1 realtime label survives this seam (read it back with {@link queryRealtimeLabel}) —
930
+ // the lease path looks up (room profile, args mapping) by query name. Unlabeled queries get
931
+ // the exact bare wrapper they always did.
932
+ out[query.queryName] = query.realtime === undefined ? wrapped : attachRealtimeLabel(wrapped, query.realtime);
855
933
  }
856
934
  return out;
857
935
  }
@@ -886,11 +964,42 @@ export function sharedApiMutators(registry, principal) {
886
964
  }
887
965
  return out;
888
966
  }
889
- export function queryResultToAst(result) {
890
- if (result && typeof result === "object" && "ast" in result && typeof result.ast === "function") {
891
- return result.ast();
892
- }
893
- return result;
967
+ /**
968
+ * Wrap a SHARED (generator) mutator with a row-level ACCESS GUARD the multi-tenant authz twin of
969
+ * {@link sharedApiMutators}. It parses the untrusted wire args, derives the {@link MutatorCtx}
970
+ * principal (the SAME mapping you pass to `sharedApiMutators`), evaluates `predicate` against the OPEN
971
+ * mutation txn (so it can READ the rows the write depends on), and throws `forbidden` (403 — the
972
+ * client's optimistic write snaps back) when access is denied; otherwise it drives the SAME body the
973
+ * client predicts ({@link runSharedMutation}). Use it for the entries that need server-only authority
974
+ * the client cannot predict, OVERRIDING the auto-wrapped default (spread `sharedApiMutators(...)`
975
+ * first, then the guarded overrides win by key):
976
+ *
977
+ * ```ts
978
+ * const principal = (ctx) => ({ user: requireUser(ctx.user) });
979
+ * mutators: defineApiMutators({
980
+ * ...sharedApiMutators(sharedMutators, principal),
981
+ * updateSlide: guardMutator(sharedMutators.updateSlide, principal,
982
+ * async (tx, a, { user }) =>
983
+ * (await tx.query(q.slide.where.id(a.slideId).where(editableBy(user)).one())) != null,
984
+ * { message: "not permitted to edit this slide" }),
985
+ * }),
986
+ * ```
987
+ *
988
+ * The predicate keeps the shared body READ-FREE, so the client's `.folded` hot paths (drag/keystroke)
989
+ * still fold — the read is server-side only. Return `false` to deny (→ the default or `opts.message`
990
+ * forbidden); return `true`/nothing to allow. To reject with a different status/message (a business
991
+ * rejection, a not-found), throw a {@link RindleApiError} from inside the predicate instead. `principal`
992
+ * runs before the predicate, so it too may throw `forbidden` for an anonymous caller.
993
+ */
994
+ export function guardMutator(gen, principal, predicate, opts) {
995
+ return async (tx, raw, ctx) => {
996
+ const args = gen.args.parse(raw);
997
+ const pctx = principal(ctx);
998
+ if ((await predicate(tx, args, pctx)) === false) {
999
+ throw new RindleApiError("forbidden", opts?.message ?? "not permitted", 403);
1000
+ }
1001
+ return runSharedMutation(gen, args, pctx, tx);
1002
+ };
894
1003
  }
895
1004
  /**
896
1005
  * Dump every registered named query's wire AST — feeder 1 ("exemplar enumeration") of
@@ -906,8 +1015,9 @@ export function queryResultToAst(result) {
906
1015
  */
907
1016
  export async function dumpQueryShapes(opts) {
908
1017
  const tables = Object.values(opts.schema.tables)
909
- // Local-only tables live in the browser's memory source, never a TableSource — no indexes.
910
- .filter((t) => t.local !== true)
1018
+ // Local-only tables (both `true` and `"session"`) live in the browser's memory source,
1019
+ // never a TableSource — no indexes.
1020
+ .filter((t) => !t.local)
911
1021
  .map((t) => ({ name: t.name, primaryKey: [...t.primaryKey] }))
912
1022
  .sort((a, b) => a.name.localeCompare(b.name));
913
1023
  const queries = [];
@@ -967,6 +1077,76 @@ function normalizeCondition(c) {
967
1077
  };
968
1078
  }
969
1079
  }
1080
+ /**
1081
+ * The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
1082
+ * transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
1083
+ * mutator uses), but lets the AUTHOR decide when it opens, so server-only work can run outside it.
1084
+ *
1085
+ * It records its outcome so the harness — not the author — enforces the `lmid`-always-advances
1086
+ * invariant: `phase` reports whether the tx committed, business-rejected, or never ran, and `infra`
1087
+ * latches a backend (DB) failure. Because the backend's `runMutation` RETURNS `{accepted:false}` for
1088
+ * a business rejection (having already advanced `lmid` alone) and THROWS only for infra, `transact`
1089
+ * can cleanly re-throw {@link MutationRejected} on the former (for author compensation) and propagate
1090
+ * the raw driver error on the latter.
1091
+ */
1092
+ class MutationScopeImpl {
1093
+ attempted = false;
1094
+ backend;
1095
+ envelope;
1096
+ render;
1097
+ /** Set once `transact` resolved through the backend (accepted OR business-rejected). */
1098
+ outcome;
1099
+ /** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
1100
+ * advance. Its presence is tracked by {@link infraLatched}, NOT by testing this for `undefined`:
1101
+ * a driver may legitimately reject with a falsy value, and misreading that as "no infra" would
1102
+ * reclassify a lost-connection failure as a business rejection and wrongly advance `lmid`. */
1103
+ infra;
1104
+ /** True once an INFRA failure latched, regardless of its (possibly falsy) value. */
1105
+ infraLatched = false;
1106
+ /** The in-flight `transact` promise. `settle` awaits it before sealing, so a transact the author
1107
+ * FORGOT to await (a floating promise — nothing here lints against it) is still resolved to its
1108
+ * real outcome first; otherwise the seal would read `untouched`, reply with a phantom no-op, and
1109
+ * let the real write commit out-of-band after the response was already sent. */
1110
+ pending;
1111
+ constructor(backend, envelope, render) {
1112
+ this.backend = backend;
1113
+ this.envelope = envelope;
1114
+ this.render = render;
1115
+ }
1116
+ transact(first, args, ctx) {
1117
+ if (this.attempted)
1118
+ throw new Error("scope.transact may be called at most once per mutation");
1119
+ this.attempted = true;
1120
+ const promise = this.drive(first, args, ctx);
1121
+ // Record the in-flight promise so `settle` can await it even when the author didn't. Errors are
1122
+ // latched onto `this` (outcome / infra), so this tracking copy swallows them — the author's
1123
+ // returned `promise` still rejects for them to await/catch.
1124
+ this.pending = promise.then(() => undefined, () => undefined);
1125
+ return promise;
1126
+ }
1127
+ async drive(first, args, ctx) {
1128
+ // A shared (generator) mutator is driven via the isomorphic seam; a plain callback gets the raw tx.
1129
+ const run = isGeneratorMutator(first)
1130
+ ? async (tx) => {
1131
+ await runSharedMutation(first, args, ctx, tx);
1132
+ }
1133
+ : async (tx) => {
1134
+ await first(tx);
1135
+ };
1136
+ let outcome;
1137
+ try {
1138
+ outcome = await this.backend.runMutation({ envelope: this.envelope, render: this.render, run });
1139
+ }
1140
+ catch (err) {
1141
+ this.infra = err; // the backend throws ONLY for infra; a business rejection returns {accepted:false}
1142
+ this.infraLatched = true;
1143
+ throw err;
1144
+ }
1145
+ this.outcome = outcome;
1146
+ if (!outcome.accepted)
1147
+ throw new MutationRejected(outcome.reason);
1148
+ }
1149
+ }
970
1150
  export function createRindleApiServer(opts) {
971
1151
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
972
1152
  const mode = opts.mode ?? "normalized";
@@ -979,6 +1159,22 @@ export function createRindleApiServer(opts) {
979
1159
  // Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
980
1160
  // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
981
1161
  const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
1162
+ // Rindle Realtime declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a):
1163
+ // compile the named room profiles and run every "loud at registration" (§2.3) check NOW —
1164
+ // construction is the moment a misconfigured profile or label can still fail the deploy,
1165
+ // not a 3am room boot. The legacy flat `resolveFootprint` stays the anonymous profile and
1166
+ // is deliberately NOT probed or validated (byte-identical legacy behavior).
1167
+ const realtime = opts.realtime;
1168
+ const roomProfiles = compileRoomProfiles({
1169
+ rooms: realtime?.rooms,
1170
+ schema: opts.schema,
1171
+ warn: realtime?.warn,
1172
+ });
1173
+ assertLabeledProfilesExist(opts.queries, roomProfiles);
1174
+ if (realtime !== undefined && roomProfiles.size === 0 && realtime.resolveFootprint === undefined) {
1175
+ throw new Error("realtime: configure at least one room profile (realtime.rooms) or the legacy resolveFootprint — " +
1176
+ "a realtime host with neither can never boot a room.");
1177
+ }
982
1178
  // Resolve a named query (+ args) to its AST under a given context — the shared path for both
983
1179
  // a per-viewer lease and a system-level pin (which skips per-user authorization).
984
1180
  const resolveAst = async (name, args, context) => {
@@ -990,6 +1186,341 @@ export function createRindleApiServer(opts) {
990
1186
  : await query(context, args);
991
1187
  return queryResultToAst(result);
992
1188
  };
1189
+ // ---------------------------------------------------------------- the room-serve decision
1190
+ //
1191
+ // RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 lease-flow steps 2–5, slice G-iv-b. Everything here is
1192
+ // FAIL-OPEN: any missing wiring, refused proof, or thrown error means the lease is served from
1193
+ // the daemon EXACTLY as today (no `realtime` block, top-level fields untouched) plus a one-time
1194
+ // diagnostic — a coverage/config problem must never block a lease.
1195
+ const realtimeWarn = realtime?.warn ?? ((message) => console.warn(message));
1196
+ // One-time per (queryName, profile): the serve decision runs on EVERY lease, so an uncovered
1197
+ // labeled query would otherwise warn once per viewer per mount.
1198
+ const warnedRoomServe = new Set();
1199
+ const warnRoomServeOnce = (queryName, profile, reasons) => {
1200
+ const key = `${queryName}\u0000${profile}`;
1201
+ if (warnedRoomServe.has(key))
1202
+ return;
1203
+ warnedRoomServe.add(key);
1204
+ realtimeWarn(`query "${queryName}" is labeled realtime (room profile "${profile}") but is NOT room-served — ` +
1205
+ `${reasons.join("; ")}. It serves from the daemon (correct, just not room-accelerated). ` +
1206
+ `This warning fires once per (query, profile).`);
1207
+ };
1208
+ // The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
1209
+ // an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
1210
+ const AGGREGATE_REFUSAL = "the query AST contains an aggregate/reduce shape — aggregate overlays are daemon-gated until post-G";
1211
+ // Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
1212
+ // AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
1213
+ // (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
1214
+ // separate slot precisely because anything that changes the verdict must change one of the two
1215
+ // ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
1216
+ // `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
1217
+ // Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
1218
+ const coverVerdicts = new Map();
1219
+ const COVER_VERDICT_CACHE_MAX = 1024;
1220
+ const maybeRoomServe = async (input, queryAst, context, subject) => {
1221
+ // (a) the label + (b) its profile — the fast bail keeps unlabeled leases byte-identical.
1222
+ const label = queryRealtimeLabel(opts.queries?.[input.name]);
1223
+ if (label === undefined)
1224
+ return undefined;
1225
+ const profile = roomProfiles.get(label.room);
1226
+ if (profile === undefined)
1227
+ return undefined; // unreachable: construction asserted it exists
1228
+ try {
1229
+ // (c) the wiring gates — each absence fail-opens with a one-time, named reason.
1230
+ if (realtime?.locateRoom === undefined) {
1231
+ warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
1232
+ return undefined;
1233
+ }
1234
+ const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
1235
+ if (coverQuery === undefined) {
1236
+ warnRoomServeOnce(input.name, profile.name, [
1237
+ "the configured daemon client does not implement coverQuery (/cover-check)",
1238
+ ]);
1239
+ return undefined;
1240
+ }
1241
+ const tokenKey = realtime.roomTokenKey;
1242
+ if (tokenKey === undefined) {
1243
+ warnRoomServeOnce(input.name, profile.name, [
1244
+ "realtime.roomTokenKey is not configured — the room lease token cannot be signed",
1245
+ ]);
1246
+ return undefined;
1247
+ }
1248
+ // The token's subject: the same resolved subject the daemon lease carries, else the
1249
+ // browser's clientId. The shell refuses a subject-less token, so with neither we fail open.
1250
+ const sub = subject ?? input.clientId;
1251
+ if (sub === undefined) {
1252
+ warnRoomServeOnce(input.name, profile.name, [
1253
+ "no token subject — configure `subject` (or have the client send clientId)",
1254
+ ]);
1255
+ return undefined;
1256
+ }
1257
+ // §2.1: (roomProfile, roomArgs) via the label's args mapping; key + doc minted SERVER-side
1258
+ // (input.args just passed the query's own validation inside resolveAst).
1259
+ const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
1260
+ const key = profile.key(roomArgs);
1261
+ const doc = mintRoomDoc(profile.name, key);
1262
+ // The profile footprint for THIS key under the request ctx (works for non-static
1263
+ // profiles), with the §2.3 unwindowed backstop `/room-boot` also applies.
1264
+ const footprintAst = queryResultToAst(await profile.footprint(key, context));
1265
+ assertUnwindowedFootprint(footprintAst, profile.name);
1266
+ const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
1267
+ let verdict = coverVerdicts.get(verdictKey);
1268
+ if (verdict === undefined) {
1269
+ verdict = astHasAggregate(queryAst)
1270
+ ? { covered: false, reasons: [AGGREGATE_REFUSAL] }
1271
+ : await coverQuery({ footprint: footprintAst, query: queryAst });
1272
+ // (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
1273
+ // a transient daemon failure doesn't pin an uncovered verdict.)
1274
+ if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
1275
+ coverVerdicts.delete(coverVerdicts.keys().next().value);
1276
+ }
1277
+ coverVerdicts.set(verdictKey, verdict);
1278
+ }
1279
+ if (!verdict.covered) {
1280
+ warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
1281
+ return undefined;
1282
+ }
1283
+ // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field — the
1284
+ // top-level `wsEndpoint` stays exactly the daemon lease's (whole-session migration signal).
1285
+ const { wsEndpoint } = await realtime.locateRoom(doc);
1286
+ const now = Date.now();
1287
+ const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
1288
+ const { mintRoomToken, scopeSpecsHash } = await loadRoomTokenModule();
1289
+ // The lease-wire specs, hashed ONCE: the same value is stamped on the token (so the
1290
+ // shell can flag scope skew — a profile edited under a live room, whose gate armed
1291
+ // with the OLD specs at boot) and returned as the client's `tables`.
1292
+ const tables = compileRoomTableSpecs(footprintAst, profile.context);
1293
+ const roomToken = await mintRoomToken({
1294
+ doc,
1295
+ ast: queryAst, // the APPROVED resolved AST — the client carries it, it can't mint/alter it
1296
+ sub,
1297
+ kid: tokenKey.kid,
1298
+ key: tokenKey.secret,
1299
+ ttlMs,
1300
+ now,
1301
+ scopesHash: scopeSpecsHash(tables),
1302
+ });
1303
+ return {
1304
+ // `parse_source_key` (rust/src/wasm/db.rs): anything but the reserved "daemon" is a room
1305
+ // source; the client-store convention is `room:` + the wire doc.
1306
+ sourceKey: `room:${doc}`,
1307
+ wsEndpoint,
1308
+ roomToken,
1309
+ exp: now + ttlMs,
1310
+ doc,
1311
+ tables,
1312
+ };
1313
+ }
1314
+ catch (e) {
1315
+ // Fail open — a lease is never blocked on the proof. Not cached (may be transient).
1316
+ warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
1317
+ return undefined;
1318
+ }
1319
+ };
1320
+ // ---------------------------------------------------------------- the §4 lifecycle mint (I-iii)
1321
+ //
1322
+ // Gated on the OPT-IN `realtime.lifecycle` block: absent, this whole section is dead code and
1323
+ // every lease response is byte-identical to pre-lifecycle. Present, a labeled lease gains the
1324
+ // doorbell system lease and a ROOM-SERVED one the fence bundle (see {@link QueryLeaseLifecycle}).
1325
+ // FAIL-OPEN like the room-serve decision: a mint failure (e.g. a daemon that never ran
1326
+ // `enable_realtime_lifecycle`) warns once per query and the lease ships without the block.
1327
+ const warnedLifecycle = new Set();
1328
+ const warnLifecycleOnce = (queryName, reason) => {
1329
+ if (warnedLifecycle.has(queryName))
1330
+ return;
1331
+ warnedLifecycle.add(queryName);
1332
+ realtimeWarn(`query "${queryName}" is realtime-labeled with lifecycle configured, but its lifecycle ` +
1333
+ `system leases were not minted — ${reason}. The lease serves without the lifecycle block ` +
1334
+ `(correct, just no §4 upgrade/downgrade plane). This warning fires once per query.`);
1335
+ };
1336
+ /** THE SCOPE-KEY DECISION (§4.1): the doorbell scope IS the wire room doc — `"<profile>/<key>"`
1337
+ * via {@link mintRoomDoc}, the same computation `maybeRoomServe` runs (label args mapping →
1338
+ * `profile.key`) and the same key `locateRoom`/`/room-boot` address the room by. Occupancy
1339
+ * (I-iv writes the `_rindle_scope_sessions` rows) must be counted on EXACTLY the key the 1→2
1340
+ * transition provisions, and this is that key. Computed independently of the room-serve
1341
+ * decision on purpose: the doorbell rides every LABELED lease — an uncovered/unwired labeled
1342
+ * query still counts toward occupancy (its collaborators still want the upgrade). */
1343
+ const lifecycleScopeDoc = (input) => {
1344
+ const label = queryRealtimeLabel(opts.queries?.[input.name]);
1345
+ if (label === undefined)
1346
+ return undefined; // unlabeled — no scope to count on
1347
+ const profile = roomProfiles.get(label.room);
1348
+ if (profile === undefined)
1349
+ return undefined; // unreachable: construction asserted it exists
1350
+ const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
1351
+ return mintRoomDoc(profile.name, profile.key(roomArgs));
1352
+ };
1353
+ const maybeLifecycle = async (input, roomServed, subject, routingKey) => {
1354
+ if (realtime?.lifecycle === undefined)
1355
+ return undefined; // the opt-in gate — mint NOTHING
1356
+ try {
1357
+ const doc = lifecycleScopeDoc(input);
1358
+ if (doc === undefined)
1359
+ return undefined;
1360
+ // Each system lease is an ordinary daemon materialization (the room-boot direct pattern),
1361
+ // carrying the SAME subject/routingKey as the primary lease so a routed deploy co-locates
1362
+ // the system streams on the follower the client's daemon session already lives on. The
1363
+ // daemon dedups by canonical query, so N clients' doorbells over one scope share ONE
1364
+ // materialization (each still minting its own leaseToken); the client-scoped fence ASTs
1365
+ // are per-client by construction.
1366
+ const mint = (ast) => opts.daemon.materialize({
1367
+ ast,
1368
+ mode,
1369
+ subject,
1370
+ leaseTtlMs: opts.leaseTtlMs,
1371
+ metadata: routingKey !== undefined ? { routingKey } : undefined,
1372
+ });
1373
+ const lease = (table, out, id) => ({
1374
+ table,
1375
+ leaseToken: out.leaseToken,
1376
+ ...(out.wsEndpoint !== undefined ? { wsEndpoint: out.wsEndpoint } : {}),
1377
+ ...(id.scope !== undefined ? { scope: id.scope } : {}),
1378
+ ...(id.doc !== undefined ? { doc: id.doc } : {}),
1379
+ ...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
1380
+ });
1381
+ const lifecycle = {
1382
+ doorbell: lease(SCOPE_SESSIONS_TABLE, await mint(scopeSessionsAst(doc)), { scope: doc }),
1383
+ };
1384
+ // The fence bundle only where a room domain exists to fence (room-served leases): the
1385
+ // §4.2 watermark, the §7.1 daemon-carried ledger, and the §3.3 outcome rows.
1386
+ if (roomServed) {
1387
+ const clientId = input.clientId;
1388
+ lifecycle.fence = [
1389
+ lease(ROOM_WATERMARK_TABLE, await mint(roomWatermarkAst(doc)), { doc }),
1390
+ lease(ROOM_CLIENT_MUTATIONS_TABLE, await mint(docClientAst(ROOM_CLIENT_MUTATIONS_TABLE, doc, clientId)), { doc, clientId }),
1391
+ lease(ROOM_MUTATION_OUTCOMES_TABLE, await mint(docClientAst(ROOM_MUTATION_OUTCOMES_TABLE, doc, clientId)), { doc, clientId }),
1392
+ ];
1393
+ }
1394
+ return lifecycle;
1395
+ }
1396
+ catch (e) {
1397
+ warnLifecycleOnce(input.name, errMessage(e)); // fail open — a lease is never blocked
1398
+ return undefined;
1399
+ }
1400
+ };
1401
+ // ------------------------------------------------------------ the §4.1 occupancy gate (I-iv)
1402
+ //
1403
+ // Runs on EVERY labeled lease under the opt-in `realtime.lifecycle` config (mint AND renewal —
1404
+ // both land on this same route), BEFORE the room-serve decision: (1) sweep + upsert the
1405
+ // caller's session row through the normal write path (the write is the doorbell — I-i's CDC
1406
+ // capture fans the row delta to every solo watcher's doorbell subscription), then (2) read the
1407
+ // occupancy count and return the D6 gate verdict `maybeRoomServe` is conditioned on. The upsert
1408
+ // deliberately precedes the count so the caller's own row is on disk when the verdict is
1409
+ // computed (its own presence rides the `+ 1`, and — more importantly — a concurrent second
1410
+ // client's read sees it). Ordering within the pair is otherwise value-neutral: the count
1411
+ // EXCLUDES the caller's clientId and adds the `+ 1` analytically.
1412
+ //
1413
+ // THE RENEWAL-vs-FRESH DECISION (grounded here because the task forces it): this server is
1414
+ // stateless and the lease request carries no "I am currently room-attached" field, so the gate
1415
+ // CANNOT distinguish a fresh mint from a live room's renewal. Instead of gating on the raw
1416
+ // count (which would suppress a momentarily-solo room's renewal and force the loud client-side
1417
+ // downgrade anomaly), the gate applies the §9.1 hysteresis DIRECTLY FROM THE LINGERING ROWS the
1418
+ // D4 sweep preserves: room-serve iff `liveOthers + self ≥ minSessions` OR some other session
1419
+ // expired within `graceMs`. A renewal is therefore never suppressed until the scope has been
1420
+ // solo SUSTAINED past the grace window — which is exactly Slice I-v's downgrade condition, read
1421
+ // from the same rows; I-v replaces that post-grace loud suppression with the fenced downgrade
1422
+ // dance, refining (not re-deciding) this verdict. A truly fresh solo scope (no other row, live
1423
+ // or lingering) is suppressed immediately — the D6 point.
1424
+ //
1425
+ // Timestamps are `Date.now()` server-side throughout (mint, sweep, count): occupancy tolerates
1426
+ // clock skew between api-server instances up to ~grace — a skewed `now` moves a session between
1427
+ // "live" and "in-grace", both of which hold the gate open; only skew past the grace+slack band
1428
+ // could mis-sweep, and the slack exists to keep that band clear.
1429
+ //
1430
+ // A request with NO `clientId` (a non-shipped client — the shipped one always sends it, see
1431
+ // `postLease`) upserts NO row and contributes NOTHING to occupancy, including to its own gate:
1432
+ // it room-serves only if the OTHER sessions alone reach `minSessions` (there is no session
1433
+ // identity to count it under, D7). It still gets its doorbell (`maybeLifecycle` is independent).
1434
+ //
1435
+ // FAIL-OPEN, like every lifecycle surface: an occupancy failure (e.g. a daemon that never ran
1436
+ // `enable_realtime_lifecycle`) warns once per query and returns `true` — the gate falls away
1437
+ // and the lease serves exactly as pre-I-iv. Suppressing on infrastructure failure would turn
1438
+ // realtime off fleet-wide from one missing table; never block, never suppress, on an error.
1439
+ const warnedOccupancy = new Set();
1440
+ const lifecycleOccupancy = async (input) => {
1441
+ const lc = realtime?.lifecycle;
1442
+ if (lc === undefined)
1443
+ return { gateOpen: true, roomPlausible: false, doc: undefined }; // lifecycle off — the gate does not exist (inert-until-fed)
1444
+ const doc = lifecycleScopeDoc(input);
1445
+ if (doc === undefined)
1446
+ return { gateOpen: true, roomPlausible: false, doc: undefined }; // unlabeled — no scope to count on, nothing to gate
1447
+ try {
1448
+ const now = Date.now();
1449
+ const minSessions = lc.minSessions ?? DEFAULT_LIFECYCLE_MIN_SESSIONS;
1450
+ const graceMs = lc.graceMs ?? DEFAULT_LIFECYCLE_GRACE_MS;
1451
+ const sessionTtlMs = lc.sessionTtlMs ?? opts.leaseTtlMs ?? DEFAULT_SESSION_TTL_MS;
1452
+ const clientId = input.clientId;
1453
+ // (1) sweep + upsert, ONE write txn (D4: the sweep shares the upsert's transaction — no
1454
+ // separate maintenance pass, and the linger bound holds atomically with the refresh).
1455
+ const statements = [
1456
+ { sql: SESSION_SWEEP_SQL, params: [doc, now - (graceMs + SESSION_SWEEP_SLACK_MS)] },
1457
+ ];
1458
+ if (clientId !== undefined) {
1459
+ statements.push({ sql: SESSION_UPSERT_SQL, params: [doc, clientId, now + sessionTtlMs] });
1460
+ }
1461
+ await opts.daemon.executeSqlTxn({ statements });
1462
+ // (2) the count — read-your-writes via `consistency: "strong"` (see the section note above).
1463
+ const read = await opts.daemon.executeSqlRead({
1464
+ sql: clientId !== undefined ? SESSION_COUNT_OTHERS_SQL : SESSION_COUNT_SQL,
1465
+ params: clientId !== undefined
1466
+ ? [now, now, now - graceMs, doc, clientId]
1467
+ : [now, now, now - graceMs, doc],
1468
+ consistency: "strong",
1469
+ });
1470
+ const cells = read.rows[0] ?? [];
1471
+ const liveOthers = Number(cells[0] ?? 0); // SUM over zero rows is NULL — coerce
1472
+ const graceOthers = Number(cells[1] ?? 0);
1473
+ const totalOthers = Number(cells[2] ?? 0); // ALL other rows (any expiry, pre-sweep)
1474
+ const self = clientId !== undefined ? 1 : 0;
1475
+ return {
1476
+ gateOpen: liveOthers + self >= minSessions || graceOthers > 0,
1477
+ // Room plausibly exists ⇒ this scope was shared (a room was provisioned on the 1→2). A
1478
+ // never-shared solo doc has NO other row and must never drain (no wasted room boot).
1479
+ roomPlausible: totalOthers > 0,
1480
+ doc,
1481
+ };
1482
+ }
1483
+ catch (e) {
1484
+ if (!warnedOccupancy.has(input.name)) {
1485
+ warnedOccupancy.add(input.name);
1486
+ realtimeWarn(`query "${input.name}" is realtime-labeled with lifecycle configured, but the §4.1 ` +
1487
+ `occupancy step failed — ${errMessage(e)}. The occupancy gate fail-opens (the lease ` +
1488
+ `serves exactly as pre-I-iv; no session row was counted). This warning fires once per query.`);
1489
+ }
1490
+ return { gateOpen: true, roomPlausible: false, doc };
1491
+ }
1492
+ };
1493
+ // ------------------------------------------------------------ the §4.2 downgrade drain (I-v)
1494
+ //
1495
+ // When the occupancy gate closes for a scope a room plausibly hosted, drain that room to a
1496
+ // COMMITTED flush seq and hand it back as the fence. `drainRoom` (deployment-wired to the room
1497
+ // shell / DO `/drain`) is idempotent (concurrent api-server instances may both call it) and
1498
+ // fails OPEN — a downgrade must never block a lease, so an unconfigured or throwing hook simply
1499
+ // omits the fence (warn-once) and the client falls to its loud legacy downgrade path.
1500
+ const warnedDrain = new Set();
1501
+ const warnDrainOnce = (queryName, reason) => {
1502
+ if (warnedDrain.has(queryName))
1503
+ return;
1504
+ warnedDrain.add(queryName);
1505
+ realtimeWarn(`query "${queryName}" downgraded (occupancy gate closed) but no §4.2 fence was attached — ` +
1506
+ `${reason}. The lease ships without the fence; a room-attached client falls back to its ` +
1507
+ `loud legacy downgrade (correct, just not graceful). This warning fires once per query.`);
1508
+ };
1509
+ const maybeDrainRoom = async (queryName, doc) => {
1510
+ const drainRoom = realtime?.lifecycle?.drainRoom;
1511
+ if (drainRoom === undefined) {
1512
+ warnDrainOnce(queryName, "realtime.lifecycle.drainRoom is not configured");
1513
+ return undefined;
1514
+ }
1515
+ try {
1516
+ const { finalFlushSeq } = await drainRoom(doc);
1517
+ return { sourceKey: `room:${doc}`, doc, finalFlushSeq };
1518
+ }
1519
+ catch (e) {
1520
+ warnDrainOnce(queryName, `drainRoom threw — ${errMessage(e)}`);
1521
+ return undefined;
1522
+ }
1523
+ };
993
1524
  const createQueryLease = async (input) => {
994
1525
  const context = { user: input.user, request: input.request };
995
1526
  await assertAuthorized(opts.authorizeQuery, {
@@ -1017,7 +1548,36 @@ export function createRindleApiServer(opts) {
1017
1548
  // `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
1018
1549
  metadata: routingKey !== undefined ? { routingKey } : undefined,
1019
1550
  });
1020
- return queryLeaseResponse(out);
1551
+ const res = queryLeaseResponse(out);
1552
+ // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
1553
+ // closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
1554
+ // indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
1555
+ // below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
1556
+ const occ = await lifecycleOccupancy(input);
1557
+ // G-iv-b: a covered labeled query ADDITIONALLY gains the realtime block. The daemon lease
1558
+ // above is unconditional (and its fields untouched) — room-serving only ever adds a field,
1559
+ // so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
1560
+ const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
1561
+ if (rt !== undefined)
1562
+ res.realtime = rt;
1563
+ // I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
1564
+ // fence back so a room-attached client runs the GRACEFUL downgrade instead of the loud legacy
1565
+ // anomaly. A never-shared solo doc (`!roomPlausible`) never drains (no wasted room boot); a
1566
+ // daemon-attached client that receives a stray fence ignores it (its resolver reads only the
1567
+ // daemon fields). `drainRoom` absent/throwing ⇒ no fence (fail-open, warn-once).
1568
+ if (!occ.gateOpen && occ.roomPlausible && occ.doc !== undefined) {
1569
+ const fence = await maybeDrainRoom(input.name, occ.doc);
1570
+ if (fence !== undefined)
1571
+ res.realtimeFence = fence;
1572
+ }
1573
+ // I-iii: under the opt-in `realtime.lifecycle` config a LABELED lease additionally gains the
1574
+ // §4 system-stream block (doorbell always; the fence bundle iff room-served OR downgrade-fenced
1575
+ // — a downgrading client needs the watermark/ledger/outcome streams to run the ghost drop).
1576
+ // Same additive discipline as the realtime block: absent config ⇒ byte-identical response.
1577
+ const lc = await maybeLifecycle(input, rt !== undefined || res.realtimeFence !== undefined, subject, routingKey);
1578
+ if (lc !== undefined)
1579
+ res.lifecycle = lc;
1580
+ return res;
1021
1581
  };
1022
1582
  const readQuery = async (input) => {
1023
1583
  const context = { user: input.user, request: input.request };
@@ -1060,24 +1620,82 @@ export function createRindleApiServer(opts) {
1060
1620
  catch (err) {
1061
1621
  return reject(backend, input.envelope, errMessage(err));
1062
1622
  }
1063
- // Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
1064
- // rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1623
+ const mctx = {
1624
+ user: input.user,
1625
+ envelope: input.envelope,
1626
+ daemon: opts.daemon,
1627
+ request: input.request,
1628
+ };
1629
+ // SCOPED mutator (WORK-OUTSIDE-TX): the author controls the tx boundary via `scope.transact`,
1630
+ // running server-only code before/after it. The `lmid`-always-advances invariant is OURS, not
1631
+ // the author's — we seal the response from the scope's recorded state, so an early return, a
1632
+ // never-called transact, or a swallowed rejection can't wedge the client's pending queue.
1633
+ if (isScoped(mutator)) {
1634
+ const scope = new MutationScopeImpl(backend, input.envelope, renderIndex);
1635
+ // A throw that reaches `settle` AFTER the outcome is already sealed (post-commit effect, or a
1636
+ // compensation handler after a business rejection) can't change the response — but it must not
1637
+ // vanish. Route it to the app's hook, else log so a failed refund is never fully silent.
1638
+ const reportSealed = (err, phase) => {
1639
+ if (opts.onScopeError)
1640
+ opts.onScopeError(err, { phase, envelope: input.envelope });
1641
+ else
1642
+ console.error(`[rindle api-server] scoped mutator ${input.envelope.name}: post-${phase} code threw (outcome already sealed):`, err);
1643
+ };
1644
+ // Derive the response from the scope's OUTCOME (not the body's return), so control flow in the
1645
+ // author's function can't skip the lmid advance. `caught` distinguishes "the body threw"
1646
+ // (present, even if the thrown value was `undefined`) from "it returned cleanly".
1647
+ const settle = async (caught) => {
1648
+ // Seal from the REAL outcome even if the author forgot to `await` transact: draining its
1649
+ // in-flight promise here records the outcome/infra before we read it (else a phantom no-op
1650
+ // ships while the real write commits out-of-band). Already-resolved when it WAS awaited.
1651
+ if (scope.pending)
1652
+ await scope.pending;
1653
+ // Infra always wins: the backend threw, the commit state is unknown — never advance lmid.
1654
+ // Keyed on the latched BOOLEAN, so a driver that rejects with a falsy value is still infra.
1655
+ if (scope.infraLatched)
1656
+ throw scope.infra;
1657
+ // transact resolved (committed OR business-rejected): seal from its recorded outcome. A
1658
+ // post-commit / post-reject-compensation throw can't change the sealed outcome (its effects
1659
+ // can't roll the tx back, and lmid already advanced §2.4). Rethrowing the MutationRejected is
1660
+ // the sanctioned "compensated, stay rejected" signal — expected, not surfaced. Any OTHER throw
1661
+ // (a FAILED refund, a post-commit effect) must not vanish — surface it.
1662
+ if (scope.outcome) {
1663
+ if (caught && !(caught.err instanceof MutationRejected)) {
1664
+ reportSealed(caught.err, scope.outcome.accepted ? "committed" : "rejected");
1665
+ }
1666
+ return outcomeToResponse(scope.outcome);
1667
+ }
1668
+ // Never transacted:
1669
+ if (caught) {
1670
+ // A throw before/around transact. A BackendError is the author signaling INFRA (retry);
1671
+ // any other throw is a BUSINESS rejection — advance lmid alone so the prediction snaps back.
1672
+ if (caught.err instanceof BackendError)
1673
+ throw caught.err.driverError;
1674
+ return reject(backend, input.envelope, errMessage(caught.err));
1675
+ }
1676
+ // Clean return with no transact — an accepted no-op that STILL advances lmid (the client
1677
+ // predicted a write; its pending entry must resolve).
1678
+ return outcomeToResponse(await backend.runMutation({ envelope: input.envelope, render: renderIndex, run: async () => { } }));
1679
+ };
1680
+ try {
1681
+ await mutator(scope, input.envelope.args, mctx);
1682
+ }
1683
+ catch (err) {
1684
+ return settle({ err });
1685
+ }
1686
+ return settle();
1687
+ }
1688
+ // Run the (tx-form) mutator INSIDE the backend's transaction. A throw from the mutator body is a
1689
+ // business rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1065
1690
  const outcome = await backend.runMutation({
1066
1691
  envelope: input.envelope,
1067
1692
  render: renderIndex,
1068
1693
  run: async (tx) => {
1069
- const result = await mutator(tx, input.envelope.args, {
1070
- user: input.user,
1071
- envelope: input.envelope,
1072
- daemon: opts.daemon,
1073
- request: input.request,
1074
- });
1694
+ const result = await mutator(tx, input.envelope.args, mctx);
1075
1695
  applyResultToTx(result, tx);
1076
1696
  },
1077
1697
  });
1078
- if (outcome.accepted)
1079
- return { accepted: true, rejected: false, output: outcome.output };
1080
- return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
1698
+ return outcomeToResponse(outcome);
1081
1699
  };
1082
1700
  const pushMutations = async (input) => {
1083
1701
  const out = [];
@@ -1136,10 +1754,60 @@ export function createRindleApiServer(opts) {
1136
1754
  throw new Error(`assertPins: ${failures.length} failed — ${failures.join("; ")}`);
1137
1755
  }
1138
1756
  };
1757
+ // The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
1758
+ // `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
1759
+ // the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
1760
+ // lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
1761
+ // ignores: it answers "is this labeled query coverable", the deployable-config question.
1762
+ const validateRealtime = async (vopts) => {
1763
+ const context = { user: opts.pinUser, request: undefined };
1764
+ const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
1765
+ const verdicts = [];
1766
+ for (const [name, q] of Object.entries(opts.queries ?? {})) {
1767
+ const label = queryRealtimeLabel(q);
1768
+ if (label === undefined)
1769
+ continue;
1770
+ const profile = roomProfiles.get(label.room);
1771
+ if (profile === undefined)
1772
+ continue; // unreachable: construction asserted it exists
1773
+ for (const args of vopts?.exemplars?.[name] ?? [null]) {
1774
+ const verdict = { query: name, profile: profile.name, args, covered: false };
1775
+ try {
1776
+ const ast = await resolveAst(name, args, context);
1777
+ const roomArgs = label.args !== undefined ? label.args(args) : args;
1778
+ const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
1779
+ assertUnwindowedFootprint(footprintAst, profile.name);
1780
+ if (astHasAggregate(ast)) {
1781
+ verdict.reasons = [AGGREGATE_REFUSAL];
1782
+ }
1783
+ else if (coverQuery === undefined) {
1784
+ verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
1785
+ }
1786
+ else {
1787
+ const out = await coverQuery({ footprint: footprintAst, query: ast });
1788
+ verdict.covered = out.covered;
1789
+ if (!out.covered)
1790
+ verdict.reasons = out.reasons ?? ["not provably covered"];
1791
+ }
1792
+ }
1793
+ catch (e) {
1794
+ verdict.reasons = [errMessage(e)];
1795
+ }
1796
+ verdicts.push(verdict);
1797
+ }
1798
+ }
1799
+ const uncovered = verdicts.filter((v) => !v.covered);
1800
+ if (vopts?.strict && uncovered.length > 0) {
1801
+ throw new Error(`validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
1802
+ uncovered
1803
+ .map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
1804
+ .join(" | "));
1805
+ }
1806
+ return { verdicts, uncovered };
1807
+ };
1139
1808
  // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
1140
1809
  // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
1141
1810
  // `authorizeRoom` (trio only). Hosting an authority is never a default.
1142
- const realtime = opts.realtime;
1143
1811
  const roomAuthorizer = realtime
1144
1812
  ? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
1145
1813
  : opts.authorizeRoom;
@@ -1149,6 +1817,31 @@ export function createRindleApiServer(opts) {
1149
1817
  }
1150
1818
  await assertAuthorized(roomAuthorizer, context);
1151
1819
  };
1820
+ // §2.1 room-key routing: a "<profile>/<key>" doc resolves through its NAMED profile — with the
1821
+ // boot-time unwindowed backstop (§2.3), which covers footprints that weren't statically
1822
+ // resolvable at construction AND key-dependent branches that window only some docs. Anything
1823
+ // else falls through to the legacy single-profile alias BYTE-IDENTICALLY (the anonymous
1824
+ // profile, bare-key form). A named profile wins over a legacy doc that merely contains "/".
1825
+ // Returns the profile's context set beside the AST (H-iv-b: the scope-spec compilation needs
1826
+ // the §2.2 owned/followed split; the legacy anonymous profile has no declaration — empty set).
1827
+ const resolveRoomFootprint = async (rt, doc, context) => {
1828
+ const split = splitRoomDoc(doc);
1829
+ if (split !== undefined) {
1830
+ const profile = roomProfiles.get(split.profile);
1831
+ if (profile !== undefined) {
1832
+ const ast = queryResultToAst(await profile.footprint(split.key, context));
1833
+ assertUnwindowedFootprint(ast, profile.name);
1834
+ return { ast, contextTables: profile.context };
1835
+ }
1836
+ }
1837
+ if (rt.resolveFootprint) {
1838
+ return {
1839
+ ast: queryResultToAst(await rt.resolveFootprint(doc, context)),
1840
+ contextTables: new Set(),
1841
+ };
1842
+ }
1843
+ throw new RindleApiError("not-found", `no room profile matches doc "${doc}" — named profiles are addressed as "<profile>/<key>"`, 404);
1844
+ };
1152
1845
  // The store's verdict rides specific statuses + body shapes (fence / conflict /
1153
1846
  // identity) the room decodes — pass a daemon HTTP error through VERBATIM.
1154
1847
  const daemonVerdict = (e) => {
@@ -1169,6 +1862,7 @@ export function createRindleApiServer(opts) {
1169
1862
  createQueryLease,
1170
1863
  readQuery,
1171
1864
  assertPins,
1865
+ validateRealtime,
1172
1866
  pushMutation,
1173
1867
  pushMutations,
1174
1868
  handleApplyRowChangeTxnJson: async (body, context) => {
@@ -1200,6 +1894,7 @@ export function createRindleApiServer(opts) {
1200
1894
  handleRoomLmidsJson: async (body, context) => {
1201
1895
  await roomGate(context);
1202
1896
  const msg = parseObject(body, "room-lmids request");
1897
+ const doc = parseString(msg.doc, "doc");
1203
1898
  if (!Array.isArray(msg.clients) || msg.clients.some((c) => typeof c !== "string")) {
1204
1899
  throw new RindleApiError("bad-request", "clients must be an array of strings", 400);
1205
1900
  }
@@ -1208,7 +1903,7 @@ export function createRindleApiServer(opts) {
1208
1903
  throw new Error("the configured daemon client does not implement roomLmids");
1209
1904
  }
1210
1905
  try {
1211
- return { status: 200, body: await lmids({ clients: msg.clients }) };
1906
+ return { status: 200, body: await lmids({ doc, clients: msg.clients }) };
1212
1907
  }
1213
1908
  catch (e) {
1214
1909
  return daemonVerdict(e);
@@ -1223,7 +1918,7 @@ export function createRindleApiServer(opts) {
1223
1918
  const doc = parseString(msg.doc, "doc");
1224
1919
  if (msg.instance !== undefined)
1225
1920
  parseString(msg.instance, "instance"); // diagnostic identity only
1226
- const ast = queryResultToAst(await realtime.resolveFootprint(doc, context));
1921
+ const { ast, contextTables } = await resolveRoomFootprint(realtime, doc, context);
1227
1922
  const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
1228
1923
  if (!claim) {
1229
1924
  throw new Error("the configured daemon client does not implement claimRoomEpoch");
@@ -1251,6 +1946,9 @@ export function createRindleApiServer(opts) {
1251
1946
  const res = {
1252
1947
  epoch,
1253
1948
  upstreamLeaseToken: lease.leaseToken,
1949
+ // H-iv-b: the §3.3 commit-gate scope specs, for named-profile AND legacy docs alike
1950
+ // (the footprint AST is resolved either way; legacy has an empty context set).
1951
+ scopes: compileRoomScopeSpecs(ast, contextTables),
1254
1952
  flush: {
1255
1953
  urls: {
1256
1954
  apply: routes.applyRowChangeTxn,
@@ -1457,10 +2155,145 @@ function queryLeaseResponse(out) {
1457
2155
  function errMessage(reason) {
1458
2156
  return String(reason?.message ?? reason);
1459
2157
  }
2158
+ // --------------------------------------------------------- lifecycle system leases (Slice I-iii)
2159
+ // The four §4 lifecycle system tables, mirrored VERBATIM from the daemon DDL — the source of
2160
+ // truth is `rust/rindle-replica/src/mutations.rs` (`realtime_lifecycle_ddl()` + the room-ledger
2161
+ // DDL in `enable_client_mutations`); duplicated here like `DEFAULT_ROUTES` is client-side so this
2162
+ // package needs no engine import. `Db::enable_realtime_lifecycle` REGISTERS all four, so a
2163
+ // hand-built AST over them materializes and resolves `hello` like any base table (the room-boot
2164
+ // direct-materialize pattern).
2165
+ const SCOPE_SESSIONS_TABLE = "_rindle_scope_sessions";
2166
+ const ROOM_WATERMARK_TABLE = "_rindle_room_watermark";
2167
+ const ROOM_CLIENT_MUTATIONS_TABLE = "_rindle_room_client_mutations";
2168
+ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
2169
+ // --------------------------------------------------------- occupancy counting (Slice I-iv, §4.1)
2170
+ //
2171
+ // The occupancy step rides the NORMAL surfaces end to end: the session upsert + lazy sweep are one
2172
+ // `executeSqlTxn` (a plain write txn — CDC-captured since I-i, so the row landing IS the doorbell
2173
+ // delta fanning to every subscribed solo client; no clientID/mid — a system write must never
2174
+ // advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
2175
+ // refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
2176
+ // the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
2177
+ // "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
2178
+ // upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
2179
+ // on the daemon interface and far heavier than this two-round-trip pair needs).
2180
+ /** Default {@link RindleRealtimeLifecycleOptions.minSessions} — the §4.1 1→2 trigger. */
2181
+ const DEFAULT_LIFECYCLE_MIN_SESSIONS = 2;
2182
+ /** Default {@link RindleRealtimeLifecycleOptions.graceMs} — the §9.1 hysteresis window. */
2183
+ const DEFAULT_LIFECYCLE_GRACE_MS = 120_000;
2184
+ /** Default {@link RindleRealtimeLifecycleOptions.sessionTtlMs} fallback when no `leaseTtlMs` is
2185
+ * configured either — 5 minutes, the {@link DEFAULT_ROOM_TOKEN_TTL_MS} cadence (see the field doc). */
2186
+ const DEFAULT_SESSION_TTL_MS = 5 * 60_000;
2187
+ /** Sweep slack past the grace window (D4): rows are deleted only once expired for MORE than
2188
+ * `graceMs + this` — the linger I-v's downgrade decision reads must comfortably outlive the
2189
+ * grace comparison itself under clock skew between api-server instances (occupancy tolerates
2190
+ * skew ≤ grace; the slack keeps the boundary case out of the deletable band). */
2191
+ const SESSION_SWEEP_SLACK_MS = 60_000;
2192
+ /** D7 upsert: one row per (scope, clientId) — `(scope, client_id)` is the table's PRIMARY KEY
2193
+ * (`realtime_lifecycle_ddl()`), so a renewal refreshes `expires_at` in place. */
2194
+ const SESSION_UPSERT_SQL = `INSERT INTO ${SCOPE_SESSIONS_TABLE} (scope, client_id, expires_at) VALUES (?, ?, ?) ` +
2195
+ `ON CONFLICT(scope, client_id) DO UPDATE SET expires_at = excluded.expires_at`;
2196
+ /** The D4 lazy sweep, in the SAME txn as the upsert: age out THIS scope's long-expired rows.
2197
+ * Param 2 is `now − (graceMs + SESSION_SWEEP_SLACK_MS)` — never tighter (the linger contract). */
2198
+ const SESSION_SWEEP_SQL = `DELETE FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ? AND expires_at < ?`;
2199
+ /** The occupancy read, one SELECT: cell 0 = DISTINCT unexpired sessions (`expires_at > now`;
2200
+ * distinct by construction — `(scope, client_id)` is the PK), cell 1 = sessions expired WITHIN
2201
+ * the grace window (`now − graceMs < expires_at ≤ now`) — the upward hysteresis input, cell 2 =
2202
+ * ALL matching rows regardless of expiry (the I-v "room plausibly exists" signal: a scope with
2203
+ * ANY other-session row — live OR still lingering pre-sweep — was shared, so a room was
2204
+ * provisioned; a never-shared solo doc has none and must never drain). Params:
2205
+ * `[now, now, now − graceMs, scope]`. */
2206
+ const SESSION_COUNT_SQL = `SELECT SUM(CASE WHEN expires_at > ? THEN 1 ELSE 0 END), ` +
2207
+ `SUM(CASE WHEN expires_at <= ? AND expires_at > ? THEN 1 ELSE 0 END), ` +
2208
+ `COUNT(*) ` +
2209
+ `FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ?`;
2210
+ /** {@link SESSION_COUNT_SQL} excluding the CALLER's own row (D6 counts *other* sessions; the
2211
+ * caller contributes itself as the `+ 1`). One extra trailing param: the caller's clientId. */
2212
+ const SESSION_COUNT_OTHERS_SQL = `${SESSION_COUNT_SQL} AND client_id <> ?`;
2213
+ /** `col = <string literal>` — the only predicate shape the lifecycle ASTs need. */
2214
+ function colEq(name, value) {
2215
+ return { type: "simple", op: "=", left: { type: "column", name }, right: { type: "literal", value } };
2216
+ }
2217
+ /** The doorbell AST (§4.1): every unexpired row under the scope is one live session; the row
2218
+ * delta arriving through a solo client's daemon subscription IS the 1→2 upgrade signal. The
2219
+ * expiry filter is deliberately NOT in the predicate — `expires_at > now()` would freeze `now`
2220
+ * at mint time; liveness is the READER's judgment (I-iv), the stream just carries the rows. */
2221
+ function scopeSessionsAst(scope) {
2222
+ return { table: SCOPE_SESSIONS_TABLE, where: colEq("scope", scope) };
2223
+ }
2224
+ /** The §4.2 fence AST: the doc's monotone `flush_seq` row. */
2225
+ function roomWatermarkAst(doc) {
2226
+ return { table: ROOM_WATERMARK_TABLE, where: colEq("doc", doc) };
2227
+ }
2228
+ /** The §7.1 ledger / §3.3 outcome ASTs share one shape: doc-scoped, and ADDITIONALLY
2229
+ * client-scoped when the lease request carried the browser's stable `clientId` (the same id the
2230
+ * mutation envelopes stamp, so it is exactly the ledger/outcome `client_id`). Without it the
2231
+ * predicate stays doc-only and the client filters to its own rows (defense in depth either
2232
+ * way — the client always filters). */
2233
+ function docClientAst(table, doc, clientId) {
2234
+ const docCond = colEq("doc", doc);
2235
+ return {
2236
+ table,
2237
+ where: clientId === undefined ? docCond : { type: "and", conditions: [docCond, colEq("client_id", clientId)] },
2238
+ };
2239
+ }
2240
+ // --------------------------------------------------------- room-serve helpers (G-iv-b)
2241
+ /** Default room lease token TTL: short (minutes) per RINDLE-REALTIME §4.1 — renewal is a fresh
2242
+ * lease through the api-server, never an extension of this token. */
2243
+ const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
2244
+ /** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
2245
+ * independent resolves stringify identically (the verdict-cache key). */
2246
+ function stableStringify(v) {
2247
+ return JSON.stringify(v, (_key, value) => {
2248
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2249
+ const rec = value;
2250
+ const sorted = {};
2251
+ for (const k of Object.keys(rec).sort())
2252
+ sorted[k] = rec[k];
2253
+ return sorted;
2254
+ }
2255
+ return value;
2256
+ });
2257
+ }
2258
+ /** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
2259
+ * `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
2260
+ * overlay (AGGREGATE-SYNC) is computed against the DAEMON's normalized stream and stays
2261
+ * daemon-gated until post-G. (`groupBy`/`having` only occur alongside `aggregate`, so testing
2262
+ * `aggregate` covers them; `having` is still walked for nested EXISTS aggregates.) */
2263
+ function astHasAggregate(ast) {
2264
+ if (ast.aggregate !== undefined)
2265
+ return true;
2266
+ for (const rel of ast.related ?? []) {
2267
+ if (astHasAggregate(rel.subquery))
2268
+ return true;
2269
+ }
2270
+ return conditionHasAggregate(ast.where) || conditionHasAggregate(ast.having);
2271
+ }
2272
+ function conditionHasAggregate(cond) {
2273
+ if (cond === undefined)
2274
+ return false;
2275
+ switch (cond.type) {
2276
+ case "simple":
2277
+ return false;
2278
+ case "and":
2279
+ case "or":
2280
+ return cond.conditions.some(conditionHasAggregate);
2281
+ case "correlatedSubquery":
2282
+ return astHasAggregate(cond.related.subquery);
2283
+ }
2284
+ }
1460
2285
  async function reject(backend, envelope, reason) {
1461
2286
  const output = await backend.reject({ envelope, reason });
1462
2287
  return { accepted: false, rejected: true, reason, output };
1463
2288
  }
2289
+ /** The single place a {@link MutationOutcome} becomes the wire {@link PushMutationResponse} — shared
2290
+ * by the tx-form path and every scoped-mutator seal branch so the accepted/rejected shape can never
2291
+ * drift between them. */
2292
+ function outcomeToResponse(outcome) {
2293
+ return outcome.accepted
2294
+ ? { accepted: true, rejected: false, output: outcome.output }
2295
+ : { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
2296
+ }
1464
2297
  function parseObject(value, label) {
1465
2298
  if (!value || typeof value !== "object" || Array.isArray(value)) {
1466
2299
  throw new RindleApiError("bad-request", `invalid ${label}`, 400);