@rindle/api-server 0.4.4 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,10 +248,18 @@ 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 hosted by rindled: keep it on the read/follower leg. The write master owns
252
+ // room durability, not query-cover analysis.
253
+ coverQuery(input) {
254
+ const cover = this.reads.coverQuery?.bind(this.reads);
255
+ if (!cover)
256
+ return Promise.reject(new Error("the read follower lacks coverQuery"));
257
+ return cover(input);
258
+ }
185
259
  migrate(input) {
186
260
  return this.writes.migrate(input);
187
261
  }
188
- // reads → the router (it stamps `wsEndpoint` onto the outputs)
262
+ // reads → the fleet (one FLEET_URL follower; the affinity ticket + Fly edge place the machine)
189
263
  materialize(input) {
190
264
  return this.reads.materialize(input);
191
265
  }
@@ -373,6 +447,23 @@ class AbsorbedReplay extends Error {
373
447
  super("mutation absorbed by mid dedup at session begin");
374
448
  }
375
449
  }
450
+ const MUTATOR_CONFLICT_MAX_ATTEMPTS = 5;
451
+ function isRetryableCommitConflict(error) {
452
+ if (!(error instanceof DaemonHttpError) || error.status !== 409)
453
+ return false;
454
+ try {
455
+ const body = JSON.parse(error.body);
456
+ return body.code === "retryable-conflict" && body.retryable === true;
457
+ }
458
+ catch {
459
+ return false;
460
+ }
461
+ }
462
+ async function mutatorConflictBackoff(attempt) {
463
+ const ceiling = Math.min(32, 2 ** attempt);
464
+ const millis = ceiling + Math.floor(Math.random() * 4);
465
+ await new Promise((resolve) => setTimeout(resolve, millis));
466
+ }
376
467
  /**
377
468
  * The daemon server tx (DAEMON-INTERACTIVE-TXN-DESIGN.md §5): ONE authoring surface, two
378
469
  * execution strategies. It starts ACCUMULATING — a pure-write mutator ships one batch to
@@ -633,43 +724,55 @@ class PgLiveTx {
633
724
  export function daemonBackend(daemon) {
634
725
  return {
635
726
  dialect: sqliteDialect,
636
- async runMutation({ envelope, render, run }) {
637
- const tx = new DaemonLazyTx(render, daemon, envelope);
638
- try {
639
- await run(tx);
640
- }
641
- catch (err) {
642
- // A begin-absorbed replay: the authoritative outcome already committed — answer it,
643
- // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
644
- if (tx.absorbed)
645
- return { accepted: true, output: tx.absorbed };
646
- if (err instanceof BackendError) {
647
- await tx.rollbackSessionQuietly();
648
- throw err.driverError; // infra — never a user rejection
649
- }
650
- const reason = errMessage(err);
651
- // Data first, watermark second: the rollback releases the single writer that the
652
- // `/reject-mutation` lmid-only commit needs (§2.4 on the session path).
653
- await tx.rollbackSessionQuietly();
654
- const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
655
- return { accepted: false, reason, output };
656
- }
657
- if (tx.absorbed)
658
- return { accepted: true, output: tx.absorbed };
659
- if (tx.session) {
727
+ async runMutation(input) {
728
+ for (let attempt = 0; attempt < MUTATOR_CONFLICT_MAX_ATTEMPTS; attempt++) {
660
729
  try {
661
- return { accepted: true, output: await tx.commitSession() };
730
+ const { envelope, render, run } = input;
731
+ const tx = new DaemonLazyTx(render, daemon, envelope);
732
+ try {
733
+ await run(tx);
734
+ }
735
+ catch (err) {
736
+ // A begin-absorbed replay: the authoritative outcome already committed — answer it,
737
+ // whatever the body did with the unwind (§4.1; the latch, not the throw, decides).
738
+ if (tx.absorbed)
739
+ return { accepted: true, output: tx.absorbed };
740
+ if (err instanceof BackendError) {
741
+ await tx.rollbackSessionQuietly();
742
+ throw err.driverError; // infra — never a user rejection
743
+ }
744
+ const reason = errMessage(err);
745
+ // Data first, watermark second: rollback releases this session's connection before
746
+ // the lmid-only rejection commit.
747
+ await tx.rollbackSessionQuietly();
748
+ const output = await daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
749
+ return { accepted: false, reason, output };
750
+ }
751
+ if (tx.absorbed)
752
+ return { accepted: true, output: tx.absorbed };
753
+ if (tx.session) {
754
+ try {
755
+ return { accepted: true, output: await tx.commitSession() };
756
+ }
757
+ catch (err) {
758
+ if (err instanceof BackendError)
759
+ throw err.driverError;
760
+ throw err;
761
+ }
762
+ }
763
+ const txn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
764
+ if (tx.idempotencyKey !== undefined)
765
+ txn.idempotencyKey = tx.idempotencyKey;
766
+ return { accepted: true, output: await daemon.executeSqlTxn(txn) };
662
767
  }
663
- catch (err) {
664
- if (err instanceof BackendError)
665
- throw err.driverError; // infra (client retries; dedup absorbs)
666
- throw err;
768
+ catch (error) {
769
+ if (!isRetryableCommitConflict(error) || attempt + 1 === MUTATOR_CONFLICT_MAX_ATTEMPTS) {
770
+ throw error;
771
+ }
772
+ await mutatorConflictBackoff(attempt);
667
773
  }
668
774
  }
669
- const txn = { statements: [...tx.statements], clientID: envelope.clientID, mid: envelope.mid };
670
- if (tx.idempotencyKey !== undefined)
671
- txn.idempotencyKey = tx.idempotencyKey;
672
- return { accepted: true, output: await daemon.executeSqlTxn(txn) };
775
+ throw new Error("unreachable mutator conflict retry loop");
673
776
  },
674
777
  reject({ envelope, reason }) {
675
778
  return daemon.rejectMutation({ clientID: envelope.clientID, mid: envelope.mid, reason });
@@ -851,7 +954,11 @@ export function registerQueries(queries) {
851
954
  if (Object.prototype.hasOwnProperty.call(out, query.queryName)) {
852
955
  throw new Error(`registerQueries: duplicate query name "${query.queryName}"`);
853
956
  }
854
- out[query.queryName] = (ctx, args) => query.resolve(args, ctx);
957
+ const wrapped = (ctx, args) => query.resolve(args, ctx);
958
+ // The §2.1 realtime label survives this seam (read it back with {@link queryRealtimeLabel}) —
959
+ // the lease path looks up (room profile, args mapping) by query name. Unlabeled queries get
960
+ // the exact bare wrapper they always did.
961
+ out[query.queryName] = query.realtime === undefined ? wrapped : attachRealtimeLabel(wrapped, query.realtime);
855
962
  }
856
963
  return out;
857
964
  }
@@ -886,11 +993,42 @@ export function sharedApiMutators(registry, principal) {
886
993
  }
887
994
  return out;
888
995
  }
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;
996
+ /**
997
+ * Wrap a SHARED (generator) mutator with a row-level ACCESS GUARD the multi-tenant authz twin of
998
+ * {@link sharedApiMutators}. It parses the untrusted wire args, derives the {@link MutatorCtx}
999
+ * principal (the SAME mapping you pass to `sharedApiMutators`), evaluates `predicate` against the OPEN
1000
+ * mutation txn (so it can READ the rows the write depends on), and throws `forbidden` (403 — the
1001
+ * client's optimistic write snaps back) when access is denied; otherwise it drives the SAME body the
1002
+ * client predicts ({@link runSharedMutation}). Use it for the entries that need server-only authority
1003
+ * the client cannot predict, OVERRIDING the auto-wrapped default (spread `sharedApiMutators(...)`
1004
+ * first, then the guarded overrides win by key):
1005
+ *
1006
+ * ```ts
1007
+ * const principal = (ctx) => ({ user: requireUser(ctx.user) });
1008
+ * mutators: defineApiMutators({
1009
+ * ...sharedApiMutators(sharedMutators, principal),
1010
+ * updateSlide: guardMutator(sharedMutators.updateSlide, principal,
1011
+ * async (tx, a, { user }) =>
1012
+ * (await tx.query(q.slide.where.id(a.slideId).where(editableBy(user)).one())) != null,
1013
+ * { message: "not permitted to edit this slide" }),
1014
+ * }),
1015
+ * ```
1016
+ *
1017
+ * The predicate keeps the shared body READ-FREE, so the client's `.folded` hot paths (drag/keystroke)
1018
+ * still fold — the read is server-side only. Return `false` to deny (→ the default or `opts.message`
1019
+ * forbidden); return `true`/nothing to allow. To reject with a different status/message (a business
1020
+ * rejection, a not-found), throw a {@link RindleApiError} from inside the predicate instead. `principal`
1021
+ * runs before the predicate, so it too may throw `forbidden` for an anonymous caller.
1022
+ */
1023
+ export function guardMutator(gen, principal, predicate, opts) {
1024
+ return async (tx, raw, ctx) => {
1025
+ const args = gen.args.parse(raw);
1026
+ const pctx = principal(ctx);
1027
+ if ((await predicate(tx, args, pctx)) === false) {
1028
+ throw new RindleApiError("forbidden", opts?.message ?? "not permitted", 403);
1029
+ }
1030
+ return runSharedMutation(gen, args, pctx, tx);
1031
+ };
894
1032
  }
895
1033
  /**
896
1034
  * Dump every registered named query's wire AST — feeder 1 ("exemplar enumeration") of
@@ -968,6 +1106,76 @@ function normalizeCondition(c) {
968
1106
  };
969
1107
  }
970
1108
  }
1109
+ /**
1110
+ * The runtime {@link MutationScope} handed to a {@link ScopedMutator}. It owns the single atomic
1111
+ * transaction (delegating to {@link MutationBackend.runMutation} — the exact machinery a tx-form
1112
+ * mutator uses), but lets the AUTHOR decide when it opens, so server-only work can run outside it.
1113
+ *
1114
+ * It records its outcome so the harness — not the author — enforces the `lmid`-always-advances
1115
+ * invariant: `phase` reports whether the tx committed, business-rejected, or never ran, and `infra`
1116
+ * latches a backend (DB) failure. Because the backend's `runMutation` RETURNS `{accepted:false}` for
1117
+ * a business rejection (having already advanced `lmid` alone) and THROWS only for infra, `transact`
1118
+ * can cleanly re-throw {@link MutationRejected} on the former (for author compensation) and propagate
1119
+ * the raw driver error on the latter.
1120
+ */
1121
+ class MutationScopeImpl {
1122
+ attempted = false;
1123
+ backend;
1124
+ envelope;
1125
+ render;
1126
+ /** Set once `transact` resolved through the backend (accepted OR business-rejected). */
1127
+ outcome;
1128
+ /** The value the backend threw on INFRA (the DB failed) — always propagated, never an `lmid`
1129
+ * advance. Its presence is tracked by {@link infraLatched}, NOT by testing this for `undefined`:
1130
+ * a driver may legitimately reject with a falsy value, and misreading that as "no infra" would
1131
+ * reclassify a lost-connection failure as a business rejection and wrongly advance `lmid`. */
1132
+ infra;
1133
+ /** True once an INFRA failure latched, regardless of its (possibly falsy) value. */
1134
+ infraLatched = false;
1135
+ /** The in-flight `transact` promise. `settle` awaits it before sealing, so a transact the author
1136
+ * FORGOT to await (a floating promise — nothing here lints against it) is still resolved to its
1137
+ * real outcome first; otherwise the seal would read `untouched`, reply with a phantom no-op, and
1138
+ * let the real write commit out-of-band after the response was already sent. */
1139
+ pending;
1140
+ constructor(backend, envelope, render) {
1141
+ this.backend = backend;
1142
+ this.envelope = envelope;
1143
+ this.render = render;
1144
+ }
1145
+ transact(first, args, ctx) {
1146
+ if (this.attempted)
1147
+ throw new Error("scope.transact may be called at most once per mutation");
1148
+ this.attempted = true;
1149
+ const promise = this.drive(first, args, ctx);
1150
+ // Record the in-flight promise so `settle` can await it even when the author didn't. Errors are
1151
+ // latched onto `this` (outcome / infra), so this tracking copy swallows them — the author's
1152
+ // returned `promise` still rejects for them to await/catch.
1153
+ this.pending = promise.then(() => undefined, () => undefined);
1154
+ return promise;
1155
+ }
1156
+ async drive(first, args, ctx) {
1157
+ // A shared (generator) mutator is driven via the isomorphic seam; a plain callback gets the raw tx.
1158
+ const run = isGeneratorMutator(first)
1159
+ ? async (tx) => {
1160
+ await runSharedMutation(first, args, ctx, tx);
1161
+ }
1162
+ : async (tx) => {
1163
+ await first(tx);
1164
+ };
1165
+ let outcome;
1166
+ try {
1167
+ outcome = await this.backend.runMutation({ envelope: this.envelope, render: this.render, run });
1168
+ }
1169
+ catch (err) {
1170
+ this.infra = err; // the backend throws ONLY for infra; a business rejection returns {accepted:false}
1171
+ this.infraLatched = true;
1172
+ throw err;
1173
+ }
1174
+ this.outcome = outcome;
1175
+ if (!outcome.accepted)
1176
+ throw new MutationRejected(outcome.reason);
1177
+ }
1178
+ }
971
1179
  export function createRindleApiServer(opts) {
972
1180
  const routes = { ...DEFAULT_RINDLE_API_ROUTES, ...opts.routes };
973
1181
  const mode = opts.mode ?? "normalized";
@@ -980,6 +1188,22 @@ export function createRindleApiServer(opts) {
980
1188
  // Names that are ALSO configured pins — a lease for one is forced to a `pinned` policy (the lazy
981
1189
  // floor, §4.1) so the first viewer to route to a follower warms it for late joiners.
982
1190
  const pinnedNames = new Set((opts.pinnedQueries ?? []).map((p) => p.name));
1191
+ // Rindle Realtime declaration layer (RINDLE-REALTIME-QUERY-ENABLEMENT §2, slice G-iv-a):
1192
+ // compile the named room profiles and run every "loud at registration" (§2.3) check NOW —
1193
+ // construction is the moment a misconfigured profile or label can still fail the deploy,
1194
+ // not a 3am room boot. The legacy flat `resolveFootprint` stays the anonymous profile and
1195
+ // is deliberately NOT probed or validated (byte-identical legacy behavior).
1196
+ const realtime = opts.realtime;
1197
+ const roomProfiles = compileRoomProfiles({
1198
+ rooms: realtime?.rooms,
1199
+ schema: opts.schema,
1200
+ warn: realtime?.warn,
1201
+ });
1202
+ assertLabeledProfilesExist(opts.queries, roomProfiles);
1203
+ if (realtime !== undefined && roomProfiles.size === 0 && realtime.resolveFootprint === undefined) {
1204
+ throw new Error("realtime: configure at least one room profile (realtime.rooms) or the legacy resolveFootprint — " +
1205
+ "a realtime host with neither can never boot a room.");
1206
+ }
983
1207
  // Resolve a named query (+ args) to its AST under a given context — the shared path for both
984
1208
  // a per-viewer lease and a system-level pin (which skips per-user authorization).
985
1209
  const resolveAst = async (name, args, context) => {
@@ -991,6 +1215,344 @@ export function createRindleApiServer(opts) {
991
1215
  : await query(context, args);
992
1216
  return queryResultToAst(result);
993
1217
  };
1218
+ // ---------------------------------------------------------------- the room-serve decision
1219
+ //
1220
+ // RINDLE-REALTIME-QUERY-ENABLEMENT §2.1 lease-flow steps 2–5, slice G-iv-b. Everything here is
1221
+ // FAIL-OPEN: any missing wiring, refused proof, or thrown error means the lease is served from
1222
+ // the daemon EXACTLY as today (no `realtime` block, top-level fields untouched) plus a one-time
1223
+ // diagnostic — a coverage/config problem must never block a lease.
1224
+ const realtimeWarn = realtime?.warn ?? ((message) => console.warn(message));
1225
+ // One-time per (queryName, profile): the serve decision runs on EVERY lease, so an uncovered
1226
+ // labeled query would otherwise warn once per viewer per mount.
1227
+ const warnedRoomServe = new Set();
1228
+ const warnRoomServeOnce = (queryName, profile, reasons) => {
1229
+ const key = `${queryName}\u0000${profile}`;
1230
+ if (warnedRoomServe.has(key))
1231
+ return;
1232
+ warnedRoomServe.add(key);
1233
+ realtimeWarn(`query "${queryName}" is labeled realtime (room profile "${profile}") but is NOT room-served — ` +
1234
+ `${reasons.join("; ")}. It serves from the daemon (correct, just not room-accelerated). ` +
1235
+ `This warning fires once per (query, profile).`);
1236
+ };
1237
+ // The §2.3 aggregate refusal: the client's aggregate overlay is daemon-gated until post-G, so
1238
+ // an aggregate/reduce-shaped query is refused room-serving REGARDLESS of coverage.
1239
+ const AGGREGATE_REFUSAL = "the query AST contains an aggregate/reduce shape — aggregate overlays are daemon-gated until post-G";
1240
+ // Verdict cache. The verdict is a pure function of exactly two inputs — the resolved footprint
1241
+ // AST and the resolved query AST — so the tightest SOUND key is those two ASTs themselves
1242
+ // (stable-stringified), scoped by (queryName, profile) for legibility. Args/user/ctx need no
1243
+ // separate slot precisely because anything that changes the verdict must change one of the two
1244
+ // ASTs (predicate literals embed the args; ctx-scoped queries embed the principal); keying on
1245
+ // `(name, args)` alone would ALIAS two users' different ASTs under one verdict — unsound.
1246
+ // Bounded FIFO (Map iterates in insertion order) so per-user literals can't grow it forever.
1247
+ const coverVerdicts = new Map();
1248
+ const COVER_VERDICT_CACHE_MAX = 1024;
1249
+ const maybeRoomServe = async (input, queryAst, context, subject) => {
1250
+ // (a) the label + (b) its profile — the fast bail keeps unlabeled leases byte-identical.
1251
+ const label = queryRealtimeLabel(opts.queries?.[input.name]);
1252
+ if (label === undefined)
1253
+ return undefined;
1254
+ const profile = roomProfiles.get(label.room);
1255
+ if (profile === undefined)
1256
+ return undefined; // unreachable: construction asserted it exists
1257
+ try {
1258
+ // (c) the wiring gates — each absence fail-opens with a one-time, named reason.
1259
+ if (realtime?.locateRoom === undefined) {
1260
+ warnRoomServeOnce(input.name, profile.name, ["realtime.locateRoom is not configured"]);
1261
+ return undefined;
1262
+ }
1263
+ const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
1264
+ if (coverQuery === undefined) {
1265
+ warnRoomServeOnce(input.name, profile.name, [
1266
+ "the configured daemon client does not implement coverQuery (/cover-check)",
1267
+ ]);
1268
+ return undefined;
1269
+ }
1270
+ const tokenKey = realtime.roomTokenKey;
1271
+ if (tokenKey === undefined) {
1272
+ warnRoomServeOnce(input.name, profile.name, [
1273
+ "realtime.roomTokenKey is not configured — the room lease token cannot be signed",
1274
+ ]);
1275
+ return undefined;
1276
+ }
1277
+ // The token's subject: the same resolved subject the daemon lease carries, else the
1278
+ // browser's clientId. The shell refuses a subject-less token, so with neither we fail open.
1279
+ const sub = subject ?? input.clientId;
1280
+ if (sub === undefined) {
1281
+ warnRoomServeOnce(input.name, profile.name, [
1282
+ "no token subject — configure `subject` (or have the client send clientId)",
1283
+ ]);
1284
+ return undefined;
1285
+ }
1286
+ // §2.1: (roomProfile, roomArgs) via the label's args mapping; key + doc minted SERVER-side
1287
+ // (input.args just passed the query's own validation inside resolveAst).
1288
+ const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
1289
+ const key = profile.key(roomArgs);
1290
+ const doc = mintRoomDoc(profile.name, key);
1291
+ // The profile footprint for THIS key under the request ctx (works for non-static
1292
+ // profiles), with the §2.3 unwindowed backstop `/room-boot` also applies.
1293
+ const footprintAst = queryResultToAst(await profile.footprint(key, context));
1294
+ assertUnwindowedFootprint(footprintAst, profile.name);
1295
+ const verdictKey = `${input.name}\u0000${profile.name}\u0000${stableStringify(footprintAst)}\u0000${stableStringify(queryAst)}`;
1296
+ let verdict = coverVerdicts.get(verdictKey);
1297
+ if (verdict === undefined) {
1298
+ verdict = astHasAggregate(queryAst)
1299
+ ? { covered: false, reasons: [AGGREGATE_REFUSAL] }
1300
+ : await coverQuery({ footprint: footprintAst, query: queryAst });
1301
+ // (A coverQuery THROW never lands here — the outer catch fail-opens without caching, so
1302
+ // a transient daemon failure doesn't pin an uncovered verdict.)
1303
+ if (coverVerdicts.size >= COVER_VERDICT_CACHE_MAX) {
1304
+ coverVerdicts.delete(coverVerdicts.keys().next().value);
1305
+ }
1306
+ coverVerdicts.set(verdictKey, verdict);
1307
+ }
1308
+ if (!verdict.covered) {
1309
+ warnRoomServeOnce(input.name, profile.name, verdict.reasons ?? ["not provably covered"]);
1310
+ return undefined;
1311
+ }
1312
+ // Covered ⇒ assemble the realtime block. The room endpoint rides ITS OWN field
1313
+ // (`realtime.wsEndpoint`) — a separate connection from the daemon session's fixed ws host.
1314
+ const { wsEndpoint } = await realtime.locateRoom(doc);
1315
+ const now = Date.now();
1316
+ const ttlMs = realtime.roomTokenTtlMs ?? DEFAULT_ROOM_TOKEN_TTL_MS;
1317
+ const { mintRoomToken, scopeSpecsHash } = await loadRoomTokenModule();
1318
+ // The lease-wire specs, hashed ONCE: the same value is stamped on the token (so the
1319
+ // shell can flag scope skew — a profile edited under a live room, whose gate armed
1320
+ // with the OLD specs at boot) and returned as the client's `tables`.
1321
+ const tables = compileRoomTableSpecs(footprintAst, profile.context);
1322
+ const roomToken = await mintRoomToken({
1323
+ doc,
1324
+ ast: queryAst, // the APPROVED resolved AST — the client carries it, it can't mint/alter it
1325
+ sub,
1326
+ kid: tokenKey.kid,
1327
+ key: tokenKey.secret,
1328
+ ttlMs,
1329
+ now,
1330
+ scopesHash: scopeSpecsHash(tables),
1331
+ });
1332
+ return {
1333
+ // `parse_source_key` (rust/src/wasm/db.rs): anything but the reserved "daemon" is a room
1334
+ // source; the client-store convention is `room:` + the wire doc.
1335
+ sourceKey: `room:${doc}`,
1336
+ wsEndpoint,
1337
+ roomToken,
1338
+ exp: now + ttlMs,
1339
+ doc,
1340
+ tables,
1341
+ };
1342
+ }
1343
+ catch (e) {
1344
+ // Fail open — a lease is never blocked on the proof. Not cached (may be transient).
1345
+ warnRoomServeOnce(input.name, profile.name, [`room-serve failed: ${errMessage(e)}`]);
1346
+ return undefined;
1347
+ }
1348
+ };
1349
+ // ---------------------------------------------------------------- the §4 lifecycle mint (I-iii)
1350
+ //
1351
+ // Gated on the OPT-IN `realtime.lifecycle` block: absent, this whole section is dead code and
1352
+ // every lease response is byte-identical to pre-lifecycle. Present, a labeled lease gains the
1353
+ // doorbell system lease and a ROOM-SERVED one the fence bundle (see {@link QueryLeaseLifecycle}).
1354
+ // FAIL-OPEN like the room-serve decision: a mint failure (e.g. a daemon that never ran
1355
+ // `enable_realtime_lifecycle`) warns once per query and the lease ships without the block.
1356
+ const warnedLifecycle = new Set();
1357
+ const warnLifecycleOnce = (queryName, reason) => {
1358
+ if (warnedLifecycle.has(queryName))
1359
+ return;
1360
+ warnedLifecycle.add(queryName);
1361
+ realtimeWarn(`query "${queryName}" is realtime-labeled with lifecycle configured, but its lifecycle ` +
1362
+ `system leases were not minted — ${reason}. The lease serves without the lifecycle block ` +
1363
+ `(correct, just no §4 upgrade/downgrade plane). This warning fires once per query.`);
1364
+ };
1365
+ /** THE SCOPE-KEY DECISION (§4.1): the doorbell scope IS the wire room doc — `"<profile>/<key>"`
1366
+ * via {@link mintRoomDoc}, the same computation `maybeRoomServe` runs (label args mapping →
1367
+ * `profile.key`) and the same key `locateRoom`/`/room-boot` address the room by. Occupancy
1368
+ * (I-iv writes the `_rindle_scope_sessions` rows) must be counted on EXACTLY the key the 1→2
1369
+ * transition provisions, and this is that key. Computed independently of the room-serve
1370
+ * decision on purpose: the doorbell rides every LABELED lease — an uncovered/unwired labeled
1371
+ * query still counts toward occupancy (its collaborators still want the upgrade). */
1372
+ const lifecycleScopeDoc = (input) => {
1373
+ const label = queryRealtimeLabel(opts.queries?.[input.name]);
1374
+ if (label === undefined)
1375
+ return undefined; // unlabeled — no scope to count on
1376
+ const profile = roomProfiles.get(label.room);
1377
+ if (profile === undefined)
1378
+ return undefined; // unreachable: construction asserted it exists
1379
+ const roomArgs = label.args !== undefined ? label.args(input.args) : input.args;
1380
+ return mintRoomDoc(profile.name, profile.key(roomArgs));
1381
+ };
1382
+ const maybeLifecycle = async (input, roomServed, subject, routingKey) => {
1383
+ if (realtime?.lifecycle === undefined)
1384
+ return undefined; // the opt-in gate — mint NOTHING
1385
+ try {
1386
+ const doc = lifecycleScopeDoc(input);
1387
+ if (doc === undefined)
1388
+ return undefined;
1389
+ // Each system lease is an ordinary daemon materialization (the room-boot direct pattern),
1390
+ // carrying the SAME subject/routingKey as the primary lease so a routed deploy co-locates
1391
+ // the system streams on the follower the client's daemon session already lives on. The
1392
+ // daemon dedups by canonical query, so N clients' doorbells over one scope share ONE
1393
+ // materialization (each still minting its own leaseToken); the client-scoped fence ASTs
1394
+ // are per-client by construction.
1395
+ const mint = (ast) => opts.daemon.materialize({
1396
+ ast,
1397
+ mode,
1398
+ subject,
1399
+ leaseTtlMs: opts.leaseTtlMs,
1400
+ metadata: routingKey !== undefined ? { routingKey } : undefined,
1401
+ // Lifecycle leases are follower-local exactly like the primary lease. Forward the SAME
1402
+ // opaque placement ticket so every doorbell/fence materialization is minted on the
1403
+ // browser socket's follower instead of independently anycasting across the fleet.
1404
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
1405
+ });
1406
+ const lease = (table, out, id) => ({
1407
+ table,
1408
+ leaseToken: out.leaseToken,
1409
+ ...(id.scope !== undefined ? { scope: id.scope } : {}),
1410
+ ...(id.doc !== undefined ? { doc: id.doc } : {}),
1411
+ ...(id.clientId !== undefined ? { clientId: id.clientId } : {}),
1412
+ });
1413
+ const lifecycle = {
1414
+ doorbell: lease(SCOPE_SESSIONS_TABLE, await mint(scopeSessionsAst(doc)), { scope: doc }),
1415
+ };
1416
+ // The fence bundle only where a room domain exists to fence (room-served leases): the
1417
+ // §4.2 watermark, the §7.1 daemon-carried ledger, and the §3.3 outcome rows.
1418
+ if (roomServed) {
1419
+ const clientId = input.clientId;
1420
+ lifecycle.fence = [
1421
+ lease(ROOM_WATERMARK_TABLE, await mint(roomWatermarkAst(doc)), { doc }),
1422
+ lease(ROOM_CLIENT_MUTATIONS_TABLE, await mint(docClientAst(ROOM_CLIENT_MUTATIONS_TABLE, doc, clientId)), { doc, clientId }),
1423
+ lease(ROOM_MUTATION_OUTCOMES_TABLE, await mint(docClientAst(ROOM_MUTATION_OUTCOMES_TABLE, doc, clientId)), { doc, clientId }),
1424
+ ];
1425
+ }
1426
+ return lifecycle;
1427
+ }
1428
+ catch (e) {
1429
+ warnLifecycleOnce(input.name, errMessage(e)); // fail open — a lease is never blocked
1430
+ return undefined;
1431
+ }
1432
+ };
1433
+ // ------------------------------------------------------------ the §4.1 occupancy gate (I-iv)
1434
+ //
1435
+ // Runs on EVERY labeled lease under the opt-in `realtime.lifecycle` config (mint AND renewal —
1436
+ // both land on this same route), BEFORE the room-serve decision: (1) sweep + upsert the
1437
+ // caller's session row through the normal write path (the write is the doorbell — I-i's CDC
1438
+ // capture fans the row delta to every solo watcher's doorbell subscription), then (2) read the
1439
+ // occupancy count and return the D6 gate verdict `maybeRoomServe` is conditioned on. The upsert
1440
+ // deliberately precedes the count so the caller's own row is on disk when the verdict is
1441
+ // computed (its own presence rides the `+ 1`, and — more importantly — a concurrent second
1442
+ // client's read sees it). Ordering within the pair is otherwise value-neutral: the count
1443
+ // EXCLUDES the caller's clientId and adds the `+ 1` analytically.
1444
+ //
1445
+ // THE RENEWAL-vs-FRESH DECISION (grounded here because the task forces it): this server is
1446
+ // stateless and the lease request carries no "I am currently room-attached" field, so the gate
1447
+ // CANNOT distinguish a fresh mint from a live room's renewal. Instead of gating on the raw
1448
+ // count (which would suppress a momentarily-solo room's renewal and force the loud client-side
1449
+ // downgrade anomaly), the gate applies the §9.1 hysteresis DIRECTLY FROM THE LINGERING ROWS the
1450
+ // D4 sweep preserves: room-serve iff `liveOthers + self ≥ minSessions` OR some other session
1451
+ // expired within `graceMs`. A renewal is therefore never suppressed until the scope has been
1452
+ // solo SUSTAINED past the grace window — which is exactly Slice I-v's downgrade condition, read
1453
+ // from the same rows; I-v replaces that post-grace loud suppression with the fenced downgrade
1454
+ // dance, refining (not re-deciding) this verdict. A truly fresh solo scope (no other row, live
1455
+ // or lingering) is suppressed immediately — the D6 point.
1456
+ //
1457
+ // Timestamps are `Date.now()` server-side throughout (mint, sweep, count): occupancy tolerates
1458
+ // clock skew between api-server instances up to ~grace — a skewed `now` moves a session between
1459
+ // "live" and "in-grace", both of which hold the gate open; only skew past the grace+slack band
1460
+ // could mis-sweep, and the slack exists to keep that band clear.
1461
+ //
1462
+ // A request with NO `clientId` (a non-shipped client — the shipped one always sends it, see
1463
+ // `postLease`) upserts NO row and contributes NOTHING to occupancy, including to its own gate:
1464
+ // it room-serves only if the OTHER sessions alone reach `minSessions` (there is no session
1465
+ // identity to count it under, D7). It still gets its doorbell (`maybeLifecycle` is independent).
1466
+ //
1467
+ // FAIL-OPEN, like every lifecycle surface: an occupancy failure (e.g. a daemon that never ran
1468
+ // `enable_realtime_lifecycle`) warns once per query and returns `true` — the gate falls away
1469
+ // and the lease serves exactly as pre-I-iv. Suppressing on infrastructure failure would turn
1470
+ // realtime off fleet-wide from one missing table; never block, never suppress, on an error.
1471
+ const warnedOccupancy = new Set();
1472
+ const lifecycleOccupancy = async (input) => {
1473
+ const lc = realtime?.lifecycle;
1474
+ if (lc === undefined)
1475
+ return { gateOpen: true, roomPlausible: false, doc: undefined }; // lifecycle off — the gate does not exist (inert-until-fed)
1476
+ const doc = lifecycleScopeDoc(input);
1477
+ if (doc === undefined)
1478
+ return { gateOpen: true, roomPlausible: false, doc: undefined }; // unlabeled — no scope to count on, nothing to gate
1479
+ try {
1480
+ const now = Date.now();
1481
+ const minSessions = lc.minSessions ?? DEFAULT_LIFECYCLE_MIN_SESSIONS;
1482
+ const graceMs = lc.graceMs ?? DEFAULT_LIFECYCLE_GRACE_MS;
1483
+ const sessionTtlMs = lc.sessionTtlMs ?? opts.leaseTtlMs ?? DEFAULT_SESSION_TTL_MS;
1484
+ const clientId = input.clientId;
1485
+ // (1) sweep + upsert, ONE write txn (D4: the sweep shares the upsert's transaction — no
1486
+ // separate maintenance pass, and the linger bound holds atomically with the refresh).
1487
+ const statements = [
1488
+ { sql: SESSION_SWEEP_SQL, params: [doc, now - (graceMs + SESSION_SWEEP_SLACK_MS)] },
1489
+ ];
1490
+ if (clientId !== undefined) {
1491
+ statements.push({ sql: SESSION_UPSERT_SQL, params: [doc, clientId, now + sessionTtlMs] });
1492
+ }
1493
+ await opts.daemon.executeSqlTxn({ statements });
1494
+ // (2) the count — read-your-writes via `consistency: "strong"` (see the section note above).
1495
+ const read = await opts.daemon.executeSqlRead({
1496
+ sql: clientId !== undefined ? SESSION_COUNT_OTHERS_SQL : SESSION_COUNT_SQL,
1497
+ params: clientId !== undefined
1498
+ ? [now, now, now - graceMs, doc, clientId]
1499
+ : [now, now, now - graceMs, doc],
1500
+ consistency: "strong",
1501
+ });
1502
+ const cells = read.rows[0] ?? [];
1503
+ const liveOthers = Number(cells[0] ?? 0); // SUM over zero rows is NULL — coerce
1504
+ const graceOthers = Number(cells[1] ?? 0);
1505
+ const totalOthers = Number(cells[2] ?? 0); // ALL other rows (any expiry, pre-sweep)
1506
+ const self = clientId !== undefined ? 1 : 0;
1507
+ return {
1508
+ gateOpen: liveOthers + self >= minSessions || graceOthers > 0,
1509
+ // Room plausibly exists ⇒ this scope was shared (a room was provisioned on the 1→2). A
1510
+ // never-shared solo doc has NO other row and must never drain (no wasted room boot).
1511
+ roomPlausible: totalOthers > 0,
1512
+ doc,
1513
+ };
1514
+ }
1515
+ catch (e) {
1516
+ if (!warnedOccupancy.has(input.name)) {
1517
+ warnedOccupancy.add(input.name);
1518
+ realtimeWarn(`query "${input.name}" is realtime-labeled with lifecycle configured, but the §4.1 ` +
1519
+ `occupancy step failed — ${errMessage(e)}. The occupancy gate fail-opens (the lease ` +
1520
+ `serves exactly as pre-I-iv; no session row was counted). This warning fires once per query.`);
1521
+ }
1522
+ return { gateOpen: true, roomPlausible: false, doc };
1523
+ }
1524
+ };
1525
+ // ------------------------------------------------------------ the §4.2 downgrade drain (I-v)
1526
+ //
1527
+ // When the occupancy gate closes for a scope a room plausibly hosted, drain that room to a
1528
+ // COMMITTED flush seq and hand it back as the fence. `drainRoom` (deployment-wired to the room
1529
+ // shell / DO `/drain`) is idempotent (concurrent api-server instances may both call it) and
1530
+ // fails OPEN — a downgrade must never block a lease, so an unconfigured or throwing hook simply
1531
+ // omits the fence (warn-once) and the client falls to its loud legacy downgrade path.
1532
+ const warnedDrain = new Set();
1533
+ const warnDrainOnce = (queryName, reason) => {
1534
+ if (warnedDrain.has(queryName))
1535
+ return;
1536
+ warnedDrain.add(queryName);
1537
+ realtimeWarn(`query "${queryName}" downgraded (occupancy gate closed) but no §4.2 fence was attached — ` +
1538
+ `${reason}. The lease ships without the fence; a room-attached client falls back to its ` +
1539
+ `loud legacy downgrade (correct, just not graceful). This warning fires once per query.`);
1540
+ };
1541
+ const maybeDrainRoom = async (queryName, doc) => {
1542
+ const drainRoom = realtime?.lifecycle?.drainRoom;
1543
+ if (drainRoom === undefined) {
1544
+ warnDrainOnce(queryName, "realtime.lifecycle.drainRoom is not configured");
1545
+ return undefined;
1546
+ }
1547
+ try {
1548
+ const { finalFlushSeq } = await drainRoom(doc);
1549
+ return { sourceKey: `room:${doc}`, doc, finalFlushSeq };
1550
+ }
1551
+ catch (e) {
1552
+ warnDrainOnce(queryName, `drainRoom threw — ${errMessage(e)}`);
1553
+ return undefined;
1554
+ }
1555
+ };
994
1556
  const createQueryLease = async (input) => {
995
1557
  const context = { user: input.user, request: input.request };
996
1558
  await assertAuthorized(opts.authorizeQuery, {
@@ -1017,8 +1579,41 @@ export function createRindleApiServer(opts) {
1017
1579
  // The anonymous routing key rides `metadata.routingKey`; the router keys on
1018
1580
  // `subject ?? metadata.routingKey` (§2.2). Omitted when there is none.
1019
1581
  metadata: routingKey !== undefined ? { routingKey } : undefined,
1582
+ // Forward the browser's opaque affinity ticket (if any) so the fleet `fly-replay`s this
1583
+ // materialize to the follower the ws is pinned to (FOLLOWER-AFFINITY-DESIGN.md §4). Opaque —
1584
+ // never verified here. Inert when the reads client is a single daemon (no fleet edge).
1585
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
1020
1586
  });
1021
- return queryLeaseResponse(out);
1587
+ const res = queryLeaseResponse(out);
1588
+ // I-iv (§4.1): the occupancy step FIRST — session sweep+upsert, then the D6 gate verdict. A
1589
+ // closed gate suppresses the room-serve ONLY (the lease ships without the realtime block,
1590
+ // indistinguishable from an uncovered query — the fail-open daemon path) while the doorbell
1591
+ // below still rides; lifecycle-off ⇒ `gateOpen: true` unconditionally and this line is inert.
1592
+ const occ = await lifecycleOccupancy(input);
1593
+ // G-iv-b: a covered labeled query ADDITIONALLY gains the realtime block. The daemon lease
1594
+ // above is unconditional (and its fields untouched) — room-serving only ever adds a field,
1595
+ // so an uncovered/unwired/legacy lease stays byte-identical and nothing here can block one.
1596
+ const rt = occ.gateOpen ? await maybeRoomServe(input, ast, context, subject) : undefined;
1597
+ if (rt !== undefined)
1598
+ res.realtime = rt;
1599
+ // I-v (§4.2): the gate CLOSED and a room plausibly hosted this scope — drain it and ride the
1600
+ // fence back so a room-attached client runs the GRACEFUL downgrade instead of the loud legacy
1601
+ // anomaly. A never-shared solo doc (`!roomPlausible`) never drains (no wasted room boot); a
1602
+ // daemon-attached client that receives a stray fence ignores it (its resolver reads only the
1603
+ // daemon fields). `drainRoom` absent/throwing ⇒ no fence (fail-open, warn-once).
1604
+ if (!occ.gateOpen && occ.roomPlausible && occ.doc !== undefined) {
1605
+ const fence = await maybeDrainRoom(input.name, occ.doc);
1606
+ if (fence !== undefined)
1607
+ res.realtimeFence = fence;
1608
+ }
1609
+ // I-iii: under the opt-in `realtime.lifecycle` config a LABELED lease additionally gains the
1610
+ // §4 system-stream block (doorbell always; the fence bundle iff room-served OR downgrade-fenced
1611
+ // — a downgrading client needs the watermark/ledger/outcome streams to run the ghost drop).
1612
+ // Same additive discipline as the realtime block: absent config ⇒ byte-identical response.
1613
+ const lc = await maybeLifecycle(input, rt !== undefined || res.realtimeFence !== undefined, subject, routingKey);
1614
+ if (lc !== undefined)
1615
+ res.lifecycle = lc;
1616
+ return res;
1022
1617
  };
1023
1618
  const readQuery = async (input) => {
1024
1619
  const context = { user: input.user, request: input.request };
@@ -1039,11 +1634,13 @@ export function createRindleApiServer(opts) {
1039
1634
  const subject = await resolveSubject(opts.subject, input);
1040
1635
  const routingKey = await resolveRoutingKey(opts.routingKey, input);
1041
1636
  const visibilityKey = subject ?? routingKey;
1042
- const out = await opts.daemon.query({ ast, visibilityKey, ttlMs: opts.readIdleTtlMs });
1043
- const res = { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
1044
- if (out.wsEndpoint !== undefined)
1045
- res.wsEndpoint = out.wsEndpoint;
1046
- return res;
1637
+ const out = await opts.daemon.query({
1638
+ ast,
1639
+ visibilityKey,
1640
+ ttlMs: opts.readIdleTtlMs,
1641
+ ...(input.affinity !== undefined ? { affinity: input.affinity } : {}),
1642
+ });
1643
+ return { rows: out.rows, cvMin: out.cvMin, queryKey: out.queryKey };
1047
1644
  };
1048
1645
  const pushMutation = async (input) => {
1049
1646
  const context = { user: input.user, request: input.request };
@@ -1061,24 +1658,82 @@ export function createRindleApiServer(opts) {
1061
1658
  catch (err) {
1062
1659
  return reject(backend, input.envelope, errMessage(err));
1063
1660
  }
1064
- // Run the mutator INSIDE the backend's transaction. A throw from the mutator body is a business
1065
- // rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1661
+ const mctx = {
1662
+ user: input.user,
1663
+ envelope: input.envelope,
1664
+ daemon: opts.daemon,
1665
+ request: input.request,
1666
+ };
1667
+ // SCOPED mutator (WORK-OUTSIDE-TX): the author controls the tx boundary via `scope.transact`,
1668
+ // running server-only code before/after it. The `lmid`-always-advances invariant is OURS, not
1669
+ // the author's — we seal the response from the scope's recorded state, so an early return, a
1670
+ // never-called transact, or a swallowed rejection can't wedge the client's pending queue.
1671
+ if (isScoped(mutator)) {
1672
+ const scope = new MutationScopeImpl(backend, input.envelope, renderIndex);
1673
+ // A throw that reaches `settle` AFTER the outcome is already sealed (post-commit effect, or a
1674
+ // compensation handler after a business rejection) can't change the response — but it must not
1675
+ // vanish. Route it to the app's hook, else log so a failed refund is never fully silent.
1676
+ const reportSealed = (err, phase) => {
1677
+ if (opts.onScopeError)
1678
+ opts.onScopeError(err, { phase, envelope: input.envelope });
1679
+ else
1680
+ console.error(`[rindle api-server] scoped mutator ${input.envelope.name}: post-${phase} code threw (outcome already sealed):`, err);
1681
+ };
1682
+ // Derive the response from the scope's OUTCOME (not the body's return), so control flow in the
1683
+ // author's function can't skip the lmid advance. `caught` distinguishes "the body threw"
1684
+ // (present, even if the thrown value was `undefined`) from "it returned cleanly".
1685
+ const settle = async (caught) => {
1686
+ // Seal from the REAL outcome even if the author forgot to `await` transact: draining its
1687
+ // in-flight promise here records the outcome/infra before we read it (else a phantom no-op
1688
+ // ships while the real write commits out-of-band). Already-resolved when it WAS awaited.
1689
+ if (scope.pending)
1690
+ await scope.pending;
1691
+ // Infra always wins: the backend threw, the commit state is unknown — never advance lmid.
1692
+ // Keyed on the latched BOOLEAN, so a driver that rejects with a falsy value is still infra.
1693
+ if (scope.infraLatched)
1694
+ throw scope.infra;
1695
+ // transact resolved (committed OR business-rejected): seal from its recorded outcome. A
1696
+ // post-commit / post-reject-compensation throw can't change the sealed outcome (its effects
1697
+ // can't roll the tx back, and lmid already advanced §2.4). Rethrowing the MutationRejected is
1698
+ // the sanctioned "compensated, stay rejected" signal — expected, not surfaced. Any OTHER throw
1699
+ // (a FAILED refund, a post-commit effect) must not vanish — surface it.
1700
+ if (scope.outcome) {
1701
+ if (caught && !(caught.err instanceof MutationRejected)) {
1702
+ reportSealed(caught.err, scope.outcome.accepted ? "committed" : "rejected");
1703
+ }
1704
+ return outcomeToResponse(scope.outcome);
1705
+ }
1706
+ // Never transacted:
1707
+ if (caught) {
1708
+ // A throw before/around transact. A BackendError is the author signaling INFRA (retry);
1709
+ // any other throw is a BUSINESS rejection — advance lmid alone so the prediction snaps back.
1710
+ if (caught.err instanceof BackendError)
1711
+ throw caught.err.driverError;
1712
+ return reject(backend, input.envelope, errMessage(caught.err));
1713
+ }
1714
+ // Clean return with no transact — an accepted no-op that STILL advances lmid (the client
1715
+ // predicted a write; its pending entry must resolve).
1716
+ return outcomeToResponse(await backend.runMutation({ envelope: input.envelope, render: renderIndex, run: async () => { } }));
1717
+ };
1718
+ try {
1719
+ await mutator(scope, input.envelope.args, mctx);
1720
+ }
1721
+ catch (err) {
1722
+ return settle({ err });
1723
+ }
1724
+ return settle();
1725
+ }
1726
+ // Run the (tx-form) mutator INSIDE the backend's transaction. A throw from the mutator body is a
1727
+ // business rejection (roll data back, advance `lmid`); a BackendError (DB failure) rejects this promise.
1066
1728
  const outcome = await backend.runMutation({
1067
1729
  envelope: input.envelope,
1068
1730
  render: renderIndex,
1069
1731
  run: async (tx) => {
1070
- const result = await mutator(tx, input.envelope.args, {
1071
- user: input.user,
1072
- envelope: input.envelope,
1073
- daemon: opts.daemon,
1074
- request: input.request,
1075
- });
1732
+ const result = await mutator(tx, input.envelope.args, mctx);
1076
1733
  applyResultToTx(result, tx);
1077
1734
  },
1078
1735
  });
1079
- if (outcome.accepted)
1080
- return { accepted: true, rejected: false, output: outcome.output };
1081
- return { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
1736
+ return outcomeToResponse(outcome);
1082
1737
  };
1083
1738
  const pushMutations = async (input) => {
1084
1739
  const out = [];
@@ -1137,10 +1792,60 @@ export function createRindleApiServer(opts) {
1137
1792
  throw new Error(`assertPins: ${failures.length} failed — ${failures.join("; ")}`);
1138
1793
  }
1139
1794
  };
1795
+ // The explicit coverage diagnostic (the assertPins pattern: system-level, resolved under
1796
+ // `pinUser`, per-query failures collected — never strand the rest). It runs the REAL check —
1797
+ // the daemon's /cover-check on the actually-resolved ASTs — so its verdicts are exactly the
1798
+ // lease path's, minus the serving wiring (locateRoom/roomTokenKey), which it deliberately
1799
+ // ignores: it answers "is this labeled query coverable", the deployable-config question.
1800
+ const validateRealtime = async (vopts) => {
1801
+ const context = { user: opts.pinUser, request: undefined };
1802
+ const coverQuery = opts.daemon.coverQuery?.bind(opts.daemon);
1803
+ const verdicts = [];
1804
+ for (const [name, q] of Object.entries(opts.queries ?? {})) {
1805
+ const label = queryRealtimeLabel(q);
1806
+ if (label === undefined)
1807
+ continue;
1808
+ const profile = roomProfiles.get(label.room);
1809
+ if (profile === undefined)
1810
+ continue; // unreachable: construction asserted it exists
1811
+ for (const args of vopts?.exemplars?.[name] ?? [null]) {
1812
+ const verdict = { query: name, profile: profile.name, args, covered: false };
1813
+ try {
1814
+ const ast = await resolveAst(name, args, context);
1815
+ const roomArgs = label.args !== undefined ? label.args(args) : args;
1816
+ const footprintAst = queryResultToAst(await profile.footprint(profile.key(roomArgs), context));
1817
+ assertUnwindowedFootprint(footprintAst, profile.name);
1818
+ if (astHasAggregate(ast)) {
1819
+ verdict.reasons = [AGGREGATE_REFUSAL];
1820
+ }
1821
+ else if (coverQuery === undefined) {
1822
+ verdict.reasons = ["the configured daemon client does not implement coverQuery (/cover-check)"];
1823
+ }
1824
+ else {
1825
+ const out = await coverQuery({ footprint: footprintAst, query: ast });
1826
+ verdict.covered = out.covered;
1827
+ if (!out.covered)
1828
+ verdict.reasons = out.reasons ?? ["not provably covered"];
1829
+ }
1830
+ }
1831
+ catch (e) {
1832
+ verdict.reasons = [errMessage(e)];
1833
+ }
1834
+ verdicts.push(verdict);
1835
+ }
1836
+ }
1837
+ const uncovered = verdicts.filter((v) => !v.covered);
1838
+ if (vopts?.strict && uncovered.length > 0) {
1839
+ throw new Error(`validateRealtime: ${uncovered.length} labeled query verdict(s) not provably covered — ` +
1840
+ uncovered
1841
+ .map((v) => `${v.query} (profile "${v.profile}"): ${(v.reasons ?? []).join("; ")}`)
1842
+ .join(" | "));
1843
+ }
1844
+ return { verdicts, uncovered };
1845
+ };
1140
1846
  // The room write-authority gate (§5.3.1): endpoints are disabled until the app opts in —
1141
1847
  // the `realtime` block (which also activates `/room-boot`) or the deprecated bare
1142
1848
  // `authorizeRoom` (trio only). Hosting an authority is never a default.
1143
- const realtime = opts.realtime;
1144
1849
  const roomAuthorizer = realtime
1145
1850
  ? (realtime.authorize ?? defaultFlushGate(realtime.shellSecret))
1146
1851
  : opts.authorizeRoom;
@@ -1150,6 +1855,31 @@ export function createRindleApiServer(opts) {
1150
1855
  }
1151
1856
  await assertAuthorized(roomAuthorizer, context);
1152
1857
  };
1858
+ // §2.1 room-key routing: a "<profile>/<key>" doc resolves through its NAMED profile — with the
1859
+ // boot-time unwindowed backstop (§2.3), which covers footprints that weren't statically
1860
+ // resolvable at construction AND key-dependent branches that window only some docs. Anything
1861
+ // else falls through to the legacy single-profile alias BYTE-IDENTICALLY (the anonymous
1862
+ // profile, bare-key form). A named profile wins over a legacy doc that merely contains "/".
1863
+ // Returns the profile's context set beside the AST (H-iv-b: the scope-spec compilation needs
1864
+ // the §2.2 owned/followed split; the legacy anonymous profile has no declaration — empty set).
1865
+ const resolveRoomFootprint = async (rt, doc, context) => {
1866
+ const split = splitRoomDoc(doc);
1867
+ if (split !== undefined) {
1868
+ const profile = roomProfiles.get(split.profile);
1869
+ if (profile !== undefined) {
1870
+ const ast = queryResultToAst(await profile.footprint(split.key, context));
1871
+ assertUnwindowedFootprint(ast, profile.name);
1872
+ return { ast, contextTables: profile.context };
1873
+ }
1874
+ }
1875
+ if (rt.resolveFootprint) {
1876
+ return {
1877
+ ast: queryResultToAst(await rt.resolveFootprint(doc, context)),
1878
+ contextTables: new Set(),
1879
+ };
1880
+ }
1881
+ throw new RindleApiError("not-found", `no room profile matches doc "${doc}" — named profiles are addressed as "<profile>/<key>"`, 404);
1882
+ };
1153
1883
  // The store's verdict rides specific statuses + body shapes (fence / conflict /
1154
1884
  // identity) the room decodes — pass a daemon HTTP error through VERBATIM.
1155
1885
  const daemonVerdict = (e) => {
@@ -1170,6 +1900,7 @@ export function createRindleApiServer(opts) {
1170
1900
  createQueryLease,
1171
1901
  readQuery,
1172
1902
  assertPins,
1903
+ validateRealtime,
1173
1904
  pushMutation,
1174
1905
  pushMutations,
1175
1906
  handleApplyRowChangeTxnJson: async (body, context) => {
@@ -1201,6 +1932,7 @@ export function createRindleApiServer(opts) {
1201
1932
  handleRoomLmidsJson: async (body, context) => {
1202
1933
  await roomGate(context);
1203
1934
  const msg = parseObject(body, "room-lmids request");
1935
+ const doc = parseString(msg.doc, "doc");
1204
1936
  if (!Array.isArray(msg.clients) || msg.clients.some((c) => typeof c !== "string")) {
1205
1937
  throw new RindleApiError("bad-request", "clients must be an array of strings", 400);
1206
1938
  }
@@ -1209,7 +1941,7 @@ export function createRindleApiServer(opts) {
1209
1941
  throw new Error("the configured daemon client does not implement roomLmids");
1210
1942
  }
1211
1943
  try {
1212
- return { status: 200, body: await lmids({ clients: msg.clients }) };
1944
+ return { status: 200, body: await lmids({ doc, clients: msg.clients }) };
1213
1945
  }
1214
1946
  catch (e) {
1215
1947
  return daemonVerdict(e);
@@ -1224,7 +1956,7 @@ export function createRindleApiServer(opts) {
1224
1956
  const doc = parseString(msg.doc, "doc");
1225
1957
  if (msg.instance !== undefined)
1226
1958
  parseString(msg.instance, "instance"); // diagnostic identity only
1227
- const ast = queryResultToAst(await realtime.resolveFootprint(doc, context));
1959
+ const { ast, contextTables } = await resolveRoomFootprint(realtime, doc, context);
1228
1960
  const claim = opts.daemon.claimRoomEpoch?.bind(opts.daemon);
1229
1961
  if (!claim) {
1230
1962
  throw new Error("the configured daemon client does not implement claimRoomEpoch");
@@ -1252,6 +1984,9 @@ export function createRindleApiServer(opts) {
1252
1984
  const res = {
1253
1985
  epoch,
1254
1986
  upstreamLeaseToken: lease.leaseToken,
1987
+ // H-iv-b: the §3.3 commit-gate scope specs, for named-profile AND legacy docs alike
1988
+ // (the footprint AST is resolved either way; legacy has an empty context set).
1989
+ scopes: compileRoomScopeSpecs(ast, contextTables),
1255
1990
  flush: {
1256
1991
  urls: {
1257
1992
  apply: routes.applyRowChangeTxn,
@@ -1261,7 +1996,9 @@ export function createRindleApiServer(opts) {
1261
1996
  headers,
1262
1997
  },
1263
1998
  };
1264
- const upstreamWsEndpoint = realtime.upstreamWsEndpoint ?? lease.wsEndpoint;
1999
+ if (lease.affinity !== undefined)
2000
+ res.upstreamAffinity = lease.affinity;
2001
+ const upstreamWsEndpoint = realtime.upstreamWsEndpoint;
1265
2002
  if (upstreamWsEndpoint !== undefined)
1266
2003
  res.upstreamWsEndpoint = upstreamWsEndpoint;
1267
2004
  return { status: 200, body: res };
@@ -1278,6 +2015,7 @@ export function createRindleApiServer(opts) {
1278
2015
  args: msg.args ?? null,
1279
2016
  request: context.request,
1280
2017
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
2018
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
1281
2019
  });
1282
2020
  },
1283
2021
  handleReadJson: (body, context) => {
@@ -1288,6 +2026,7 @@ export function createRindleApiServer(opts) {
1288
2026
  args: msg.args ?? null,
1289
2027
  request: context.request,
1290
2028
  clientId: typeof msg.clientId === "string" ? msg.clientId : undefined,
2029
+ affinity: typeof msg.affinity === "string" ? msg.affinity : undefined,
1291
2030
  });
1292
2031
  },
1293
2032
  handleMutateJson: (body, context) => {
@@ -1444,24 +2183,155 @@ async function resolveRoutingKey(routingKey, input) {
1444
2183
  return typeof routingKey === "function" ? routingKey(input) : routingKey;
1445
2184
  }
1446
2185
  function queryLeaseResponse(out) {
1447
- const res = {
2186
+ return {
1448
2187
  leaseToken: out.leaseToken,
1449
2188
  materializationId: out.materializationId,
1450
2189
  queryKey: out.queryKey,
1451
2190
  reused: out.reused,
1452
2191
  };
1453
- // Only present in a routed deploy — absent reproduces today's single-daemon response exactly.
1454
- if (out.wsEndpoint !== undefined)
1455
- res.wsEndpoint = out.wsEndpoint;
1456
- return res;
1457
2192
  }
1458
2193
  function errMessage(reason) {
1459
2194
  return String(reason?.message ?? reason);
1460
2195
  }
2196
+ // --------------------------------------------------------- lifecycle system leases (Slice I-iii)
2197
+ // The four §4 lifecycle system tables, mirrored VERBATIM from the daemon DDL — the source of
2198
+ // truth is `rust/rindle-replica/src/mutations.rs` (`realtime_lifecycle_ddl()` + the room-ledger
2199
+ // DDL in `enable_client_mutations`); duplicated here like `DEFAULT_ROUTES` is client-side so this
2200
+ // package needs no engine import. `Db::enable_realtime_lifecycle` REGISTERS all four, so a
2201
+ // hand-built AST over them materializes and resolves `hello` like any base table (the room-boot
2202
+ // direct-materialize pattern).
2203
+ const SCOPE_SESSIONS_TABLE = "_rindle_scope_sessions";
2204
+ const ROOM_WATERMARK_TABLE = "_rindle_room_watermark";
2205
+ const ROOM_CLIENT_MUTATIONS_TABLE = "_rindle_room_client_mutations";
2206
+ const ROOM_MUTATION_OUTCOMES_TABLE = "_rindle_room_mutation_outcomes";
2207
+ // --------------------------------------------------------- occupancy counting (Slice I-iv, §4.1)
2208
+ //
2209
+ // The occupancy step rides the NORMAL surfaces end to end: the session upsert + lazy sweep are one
2210
+ // `executeSqlTxn` (a plain write txn — CDC-captured since I-i, so the row landing IS the doorbell
2211
+ // delta fanning to every subscribed solo client; no clientID/mid — a system write must never
2212
+ // advance an lmid — and no idempotencyKey — a renewal's re-upsert must re-run, that is the
2213
+ // refresh), and the count is one `executeSqlRead` with `consistency: "strong"` — the read surface
2214
+ // the api-server already has against the daemon (the `DaemonLazyTx` fallback precedent above).
2215
+ // "strong" routes the read to the WRITE MASTER in a split deploy, which just serialized our
2216
+ // upsert: read-your-writes without a mutation session (the interactive-txn machinery is optional
2217
+ // on the daemon interface and far heavier than this two-round-trip pair needs).
2218
+ /** Default {@link RindleRealtimeLifecycleOptions.minSessions} — the §4.1 1→2 trigger. */
2219
+ const DEFAULT_LIFECYCLE_MIN_SESSIONS = 2;
2220
+ /** Default {@link RindleRealtimeLifecycleOptions.graceMs} — the §9.1 hysteresis window. */
2221
+ const DEFAULT_LIFECYCLE_GRACE_MS = 120_000;
2222
+ /** Default {@link RindleRealtimeLifecycleOptions.sessionTtlMs} fallback when no `leaseTtlMs` is
2223
+ * configured either — 5 minutes, the {@link DEFAULT_ROOM_TOKEN_TTL_MS} cadence (see the field doc). */
2224
+ const DEFAULT_SESSION_TTL_MS = 5 * 60_000;
2225
+ /** Sweep slack past the grace window (D4): rows are deleted only once expired for MORE than
2226
+ * `graceMs + this` — the linger I-v's downgrade decision reads must comfortably outlive the
2227
+ * grace comparison itself under clock skew between api-server instances (occupancy tolerates
2228
+ * skew ≤ grace; the slack keeps the boundary case out of the deletable band). */
2229
+ const SESSION_SWEEP_SLACK_MS = 60_000;
2230
+ /** D7 upsert: one row per (scope, clientId) — `(scope, client_id)` is the table's PRIMARY KEY
2231
+ * (`realtime_lifecycle_ddl()`), so a renewal refreshes `expires_at` in place. */
2232
+ const SESSION_UPSERT_SQL = `INSERT INTO ${SCOPE_SESSIONS_TABLE} (scope, client_id, expires_at) VALUES (?, ?, ?) ` +
2233
+ `ON CONFLICT(scope, client_id) DO UPDATE SET expires_at = excluded.expires_at`;
2234
+ /** The D4 lazy sweep, in the SAME txn as the upsert: age out THIS scope's long-expired rows.
2235
+ * Param 2 is `now − (graceMs + SESSION_SWEEP_SLACK_MS)` — never tighter (the linger contract). */
2236
+ const SESSION_SWEEP_SQL = `DELETE FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ? AND expires_at < ?`;
2237
+ /** The occupancy read, one SELECT: cell 0 = DISTINCT unexpired sessions (`expires_at > now`;
2238
+ * distinct by construction — `(scope, client_id)` is the PK), cell 1 = sessions expired WITHIN
2239
+ * the grace window (`now − graceMs < expires_at ≤ now`) — the upward hysteresis input, cell 2 =
2240
+ * ALL matching rows regardless of expiry (the I-v "room plausibly exists" signal: a scope with
2241
+ * ANY other-session row — live OR still lingering pre-sweep — was shared, so a room was
2242
+ * provisioned; a never-shared solo doc has none and must never drain). Params:
2243
+ * `[now, now, now − graceMs, scope]`. */
2244
+ const SESSION_COUNT_SQL = `SELECT SUM(CASE WHEN expires_at > ? THEN 1 ELSE 0 END), ` +
2245
+ `SUM(CASE WHEN expires_at <= ? AND expires_at > ? THEN 1 ELSE 0 END), ` +
2246
+ `COUNT(*) ` +
2247
+ `FROM ${SCOPE_SESSIONS_TABLE} WHERE scope = ?`;
2248
+ /** {@link SESSION_COUNT_SQL} excluding the CALLER's own row (D6 counts *other* sessions; the
2249
+ * caller contributes itself as the `+ 1`). One extra trailing param: the caller's clientId. */
2250
+ const SESSION_COUNT_OTHERS_SQL = `${SESSION_COUNT_SQL} AND client_id <> ?`;
2251
+ /** `col = <string literal>` — the only predicate shape the lifecycle ASTs need. */
2252
+ function colEq(name, value) {
2253
+ return { type: "simple", op: "=", left: { type: "column", name }, right: { type: "literal", value } };
2254
+ }
2255
+ /** The doorbell AST (§4.1): every unexpired row under the scope is one live session; the row
2256
+ * delta arriving through a solo client's daemon subscription IS the 1→2 upgrade signal. The
2257
+ * expiry filter is deliberately NOT in the predicate — `expires_at > now()` would freeze `now`
2258
+ * at mint time; liveness is the READER's judgment (I-iv), the stream just carries the rows. */
2259
+ function scopeSessionsAst(scope) {
2260
+ return { table: SCOPE_SESSIONS_TABLE, where: colEq("scope", scope) };
2261
+ }
2262
+ /** The §4.2 fence AST: the doc's monotone `flush_seq` row. */
2263
+ function roomWatermarkAst(doc) {
2264
+ return { table: ROOM_WATERMARK_TABLE, where: colEq("doc", doc) };
2265
+ }
2266
+ /** The §7.1 ledger / §3.3 outcome ASTs share one shape: doc-scoped, and ADDITIONALLY
2267
+ * client-scoped when the lease request carried the browser's stable `clientId` (the same id the
2268
+ * mutation envelopes stamp, so it is exactly the ledger/outcome `client_id`). Without it the
2269
+ * predicate stays doc-only and the client filters to its own rows (defense in depth either
2270
+ * way — the client always filters). */
2271
+ function docClientAst(table, doc, clientId) {
2272
+ const docCond = colEq("doc", doc);
2273
+ return {
2274
+ table,
2275
+ where: clientId === undefined ? docCond : { type: "and", conditions: [docCond, colEq("client_id", clientId)] },
2276
+ };
2277
+ }
2278
+ // --------------------------------------------------------- room-serve helpers (G-iv-b)
2279
+ /** Default room lease token TTL: short (minutes) per RINDLE-REALTIME §4.1 — renewal is a fresh
2280
+ * lease through the api-server, never an extension of this token. */
2281
+ const DEFAULT_ROOM_TOKEN_TTL_MS = 5 * 60_000;
2282
+ /** Deterministic JSON: object keys sorted recursively, so two structurally identical ASTs from
2283
+ * independent resolves stringify identically (the verdict-cache key). */
2284
+ function stableStringify(v) {
2285
+ return JSON.stringify(v, (_key, value) => {
2286
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
2287
+ const rec = value;
2288
+ const sorted = {};
2289
+ for (const k of Object.keys(rec).sort())
2290
+ sorted[k] = rec[k];
2291
+ return sorted;
2292
+ }
2293
+ return value;
2294
+ });
2295
+ }
2296
+ /** Does the AST contain an aggregate/reduce shape ANYWHERE (root, a `related` subquery, or an
2297
+ * `EXISTS` child)? Room-serving refuses these regardless of coverage: the client's aggregate
2298
+ * overlay (AGGREGATE-SYNC) is computed against the DAEMON's normalized stream and stays
2299
+ * daemon-gated until post-G. (`groupBy`/`having` only occur alongside `aggregate`, so testing
2300
+ * `aggregate` covers them; `having` is still walked for nested EXISTS aggregates.) */
2301
+ function astHasAggregate(ast) {
2302
+ if (ast.aggregate !== undefined)
2303
+ return true;
2304
+ for (const rel of ast.related ?? []) {
2305
+ if (astHasAggregate(rel.subquery))
2306
+ return true;
2307
+ }
2308
+ return conditionHasAggregate(ast.where) || conditionHasAggregate(ast.having);
2309
+ }
2310
+ function conditionHasAggregate(cond) {
2311
+ if (cond === undefined)
2312
+ return false;
2313
+ switch (cond.type) {
2314
+ case "simple":
2315
+ return false;
2316
+ case "and":
2317
+ case "or":
2318
+ return cond.conditions.some(conditionHasAggregate);
2319
+ case "correlatedSubquery":
2320
+ return astHasAggregate(cond.related.subquery);
2321
+ }
2322
+ }
1461
2323
  async function reject(backend, envelope, reason) {
1462
2324
  const output = await backend.reject({ envelope, reason });
1463
2325
  return { accepted: false, rejected: true, reason, output };
1464
2326
  }
2327
+ /** The single place a {@link MutationOutcome} becomes the wire {@link PushMutationResponse} — shared
2328
+ * by the tx-form path and every scoped-mutator seal branch so the accepted/rejected shape can never
2329
+ * drift between them. */
2330
+ function outcomeToResponse(outcome) {
2331
+ return outcome.accepted
2332
+ ? { accepted: true, rejected: false, output: outcome.output }
2333
+ : { accepted: false, rejected: true, reason: outcome.reason, output: outcome.output };
2334
+ }
1465
2335
  function parseObject(value, label) {
1466
2336
  if (!value || typeof value !== "object" || Array.isArray(value)) {
1467
2337
  throw new RindleApiError("bad-request", `invalid ${label}`, 400);