@substrat-run/adapter-cloudflare 0.122.1 → 0.125.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/scope-do.js CHANGED
@@ -1,10 +1,11 @@
1
+ import { REWIND_REFUSED } from './rewind-refusal.js';
1
2
  import { DurableObject } from 'cloudflare:workers';
2
- import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
3
- import { ulid, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
3
+ import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
4
+ import { ulid, DO_SQL_LIMITS, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, jobRunListLimit, } from '@substrat-run/kernel';
4
5
  import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
5
6
  import { OperationQueue } from './serialization.js';
6
7
  import { doScopedSql, doSpineSql } from './sql.js';
7
- import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
8
+ import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
8
9
  import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
9
10
  /**
10
11
  * The key marking a scope DO whose storage was destroyed (`destroyStorage`).
@@ -555,8 +556,20 @@ export function defineScopeDO(modules, bareOps) {
555
556
  lastFailure = null;
556
557
  /** Passes of `applyPendingMigrations` that had work to do — see `migrationAttemptsOnInstance`. */
557
558
  migrationRuns = 0;
559
+ /**
560
+ * TEST-ONLY (#1860): no production code reads this. An exact `'ping'` is answered
561
+ * by `setWebSocketAutoResponse` without reaching `webSocketMessage` at all, and
562
+ * this count is how a test proves that from outside the object — read through
563
+ * `runInDurableObject` — rather than assuming the runtime's documented behaviour.
564
+ */
565
+ webSocketMessagesHandled = 0;
558
566
  constructor(ctx, env) {
559
567
  super(ctx, env);
568
+ // #1860: the client's 45s keep-alive (demos/ticket0/app/src/feed.ts) answered at
569
+ // the runtime level, before this object wakes for it. Without this, every idle
570
+ // subscriber's ping still reaches `webSocketMessage` and pins the DO in memory —
571
+ // exactly the hibernation cost `acceptWebSocket` (below) exists to avoid.
572
+ ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair('ping', 'pong'));
560
573
  this.sql = ctx.storage.sql;
561
574
  for (const stmt of splitSqlStatements(KERNEL_DDL)) {
562
575
  this.sql.exec(stmt);
@@ -900,22 +913,37 @@ export function defineScopeDO(modules, bareOps) {
900
913
  return { consumes, cursors };
901
914
  }
902
915
  /**
903
- * #1705 PR 2: does this DO hold a scope PROVISIONED here for `tenantId`?
916
+ * #1705 PR 2 / #1738: does this DO hold a scope PROVISIONED here for `tenantId`?
904
917
  *
905
918
  * A CP-less deployment has no directory to ask whether it serves a scope. What it does
906
- * have is what provisioning wrote: `provisionScopeLocal` projects the vertical's role
907
- * definitions under the tenant (`_substrat_roles`), and a restore re-projects them. A scope
908
- * this deployment never provisioned has none of that. Its DO is empty, and a cross-vertical
909
- * verb answering from it is a wrong answer rather than a failure: a watermark of "never
910
- * read", or a delivery journaled into a scope that is not the install. A scope provisioned
911
- * for ANOTHER tenant has roles under that tenant's id only, so the same read is K-3's pair
912
- * check too.
919
+ * have is what provisioning wrote. A scope this deployment never provisioned has none of
920
+ * it: its DO is empty, and a cross-vertical verb answering from it is a wrong answer rather
921
+ * than a failure: a watermark of "never read", or a delivery journaled into a scope that is
922
+ * not the install.
923
+ *
924
+ * The answer is the `provisioned_for` RECEIPT `applyProjection` writes (#1738): the tenant
925
+ * this scope was provisioned for, stated rather than inferred. A receipt decides alone, so
926
+ * a scope holding a receipt for ANOTHER tenant is refused whatever role rows sit in it (K-3's
927
+ * pair check), and a stray role row can no longer make a scope serve a tenant it was never
928
+ * provisioned for.
929
+ *
930
+ * A scope with NO receipt was provisioned before it existed, or has just been restored (a
931
+ * dump's receipt is dropped on import: it describes the scope the dump came from). Only
932
+ * then is the old inference used: `provisionScopeLocal` projected role definitions under
933
+ * the tenant, so a `_substrat_roles` row for it says the same thing. The next projection
934
+ * (reconcile, or the restore's repair) writes the receipt, after which the inference is
935
+ * never consulted again.
913
936
  *
914
937
  * Read without migrating, so asking about a foreign scope leaves its DO as empty as it found it.
915
938
  */
916
939
  async servesTenant(tenantId) {
917
- const hasRoles = this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_substrat_roles'`).toArray().length > 0;
918
- if (!hasRoles)
940
+ const has = (table) => this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, table).toArray().length > 0;
941
+ if (has('_substrat_meta')) {
942
+ const receipt = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
943
+ if (receipt)
944
+ return receipt.value === tenantId;
945
+ }
946
+ if (!has('_substrat_roles'))
919
947
  return false;
920
948
  return this.sql.exec('SELECT 1 FROM _substrat_roles WHERE tenant_id = ? LIMIT 1', tenantId).toArray().length > 0;
921
949
  }
@@ -1174,26 +1202,34 @@ export function defineScopeDO(modules, bareOps) {
1174
1202
  async rewindToBookmark(bookmark, opts) {
1175
1203
  const storage = this.ctx.storage;
1176
1204
  if (typeof storage.onNextSessionRestoreBookmark !== 'function') {
1177
- throw new Error('point-in-time rewind is not available on this host (PITR is production-plane only)');
1205
+ throw new Error(`${REWIND_REFUSED}point-in-time rewind is not available on this host (PITR is production-plane only)`);
1178
1206
  }
1179
1207
  const row = this.sql
1180
1208
  .exec('SELECT taken_at FROM _substrat_migration_bookmarks WHERE bookmark = ?', bookmark)
1181
1209
  .toArray()[0];
1182
1210
  if (!opts?.force) {
1183
1211
  if (!row) {
1184
- throw new Error('unknown bookmark — not one this scope recorded before a migration (force admits any bookmark Cloudflare holds)');
1212
+ throw new Error(`${REWIND_REFUSED}unknown bookmark — not one this scope recorded before a migration ` +
1213
+ '(force admits any bookmark Cloudflare holds)');
1185
1214
  }
1186
1215
  const ageMs = Date.now() - Date.parse(row.taken_at);
1187
1216
  if (ageMs > 24 * 60 * 60 * 1000) {
1188
- throw new Error(`bookmark is ${Math.round(ageMs / 3_600_000)}h old — rewinding discards EVERY write since; ` +
1217
+ throw new Error(`${REWIND_REFUSED}bookmark is ${Math.round(ageMs / 3_600_000)}h old — rewinding discards EVERY write since; ` +
1189
1218
  `use the backup restore path (#278), or force if the loss is intended`);
1190
1219
  }
1191
1220
  }
1221
+ // #1819: arming from BEFORE the call, so a probe that lands while it is in flight answers
1222
+ // armed. Left set if the call throws: that is not a refusal, and it may have armed.
1223
+ this.rewindArming = true;
1192
1224
  const confirmed = await storage.onNextSessionRestoreBookmark(bookmark);
1225
+ // #1819: from here, everything this instance writes is discarded at the restart.
1226
+ this.rewindArmed = true;
1193
1227
  // Answer first, then restart to complete the restore — an immediate abort
1194
1228
  // would take the RPC response down with it.
1195
1229
  setTimeout(() => this.ctx.abort(), 100);
1196
- return { rewindingTo: confirmed ?? bookmark };
1230
+ // #1819: which instance is doomed, so the host can tell a switch move made here from one
1231
+ // made on the restored storage after the restart.
1232
+ return { rewindingTo: confirmed ?? bookmark, instance: this.instanceId };
1197
1233
  }
1198
1234
  /**
1199
1235
  * The scope's live `connection:<id>` grant tuples (#726 gap 1) — the read half of
@@ -1249,6 +1285,21 @@ export function defineScopeDO(modules, bareOps) {
1249
1285
  this.sql.exec(seat.sql, ...seat.params);
1250
1286
  });
1251
1287
  }
1288
+ /**
1289
+ * Provisioning's seat of many tuples as ONE unit, then the directory's recorded-off
1290
+ * modules switched off in it (#1742) — the CP-full mirror of what `applyProjection` does
1291
+ * for a CP-less provision. One tuple per `seatTuple` call left a window between the seat
1292
+ * and the re-assert in which a sweep could run the grants just seated.
1293
+ */
1294
+ async seatTuples(tuples, switchOff) {
1295
+ return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
1296
+ for (const t of tuples) {
1297
+ const seat = seatScopeTuple(t.subject, t.relation, t.object, t.expires_at);
1298
+ this.sql.exec(seat.sql, ...seat.params);
1299
+ }
1300
+ return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
1301
+ }));
1302
+ }
1252
1303
  /**
1253
1304
  * The dispatch capability's admission (#726 remedy B): this delivery may read the
1254
1305
  * attachments of the entity its own spine row names, and no others.
@@ -1508,6 +1559,12 @@ export function defineScopeDO(modules, bareOps) {
1508
1559
  // that exports something, and never for a read-only session, which commits nothing.
1509
1560
  const exportTypes = impersonation?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
1510
1561
  const exportMark = exportTypes.length > 0 ? this.outboxMark() : null;
1562
+ // #1746: the same mark, when the caller asked what this call emitted. The callback
1563
+ // itself never runs here — its presence is the request, and the report travels
1564
+ // back in the envelope for the coordinator to deliver.
1565
+ const emittedMark = invokeOptions?.onEmitted !== undefined && impersonation?.mode !== 'read-only'
1566
+ ? (exportMark ?? this.outboxMark())
1567
+ : null;
1511
1568
  let result;
1512
1569
  let committedVersion = null;
1513
1570
  // #116: set when this invocation was answered from a recording rather
@@ -1641,6 +1698,13 @@ export function defineScopeDO(modules, bareOps) {
1641
1698
  // own retry backstop, which is what that backstop is for.
1642
1699
  // #1525: still inside the queued body that set it, so these deliveries are this
1643
1700
  // call's own work — the same tail its consumers' emits are stamped in.
1701
+ // #1746: read BEFORE `settleCommitted` drains the consumers, so the rows above the
1702
+ // mark are this operation's own emits. A replay committed nothing.
1703
+ let emitted;
1704
+ if (emittedMark !== null && !replayed) {
1705
+ const q = emittedSinceQuery(emittedMark, EMITTED_REPORT_CAP);
1706
+ emitted = emittedReportOf(this.sql.exec(q.sql, ...q.params).toArray());
1707
+ }
1644
1708
  if (!replayed)
1645
1709
  await this.settleCommitted(tenantId, scopeId, liveSince, this.invocationId);
1646
1710
  // After the tail, so an exported type a consumer emitted in it counts too.
@@ -1651,6 +1715,7 @@ export function defineScopeDO(modules, bareOps) {
1651
1715
  // #1705 PR 2: the coordinator fires `onExportedEvents` from this. Omitted at 0, so
1652
1716
  // the envelope of a deployment that exports nothing is byte-for-byte what it was.
1653
1717
  ...(exported > 0 ? { exported } : {}),
1718
+ ...(emitted ? { emitted } : {}),
1654
1719
  ...(impersonation ? { impersonation: { honoured: true } } : {}),
1655
1720
  // #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
1656
1721
  ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
@@ -1774,8 +1839,14 @@ export function defineScopeDO(modules, bareOps) {
1774
1839
  * Deliberately NOT a place to let a client narrow or widen what it receives: a
1775
1840
  * filter the client chooses is a filter the client can choose wrongly, and the
1776
1841
  * only filter that matters here is the one it does not control.
1842
+ *
1843
+ * #1860: an exact `'ping'` is now answered by `setWebSocketAutoResponse` in the
1844
+ * constructor, at the runtime level, without waking this object — so this branch no
1845
+ * longer runs for it. Kept as the documented fallback for a client on an older
1846
+ * build, or a hop where the runtime's auto-response is unavailable.
1777
1847
  */
1778
1848
  webSocketMessage(ws, message) {
1849
+ this.webSocketMessagesHandled++;
1779
1850
  if (message === 'ping')
1780
1851
  ws.send('pong');
1781
1852
  }
@@ -2262,7 +2333,9 @@ export function defineScopeDO(modules, bareOps) {
2262
2333
  * as one `transactionSync`, so its reads and writes are one unit.
2263
2334
  */
2264
2335
  async switchSystemSchedules(moduleId, scopeId, to, at) {
2265
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2336
+ const outcome = await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2337
+ // #1819: the instance that applied it, which the rewind hold's release rule reads.
2338
+ return { ...outcome, instance: this.instanceId };
2266
2339
  }
2267
2340
  /**
2268
2341
  * The replay lever on this scope (#1705 PR 3): the kernel's `moveImportCursor`, queued with
@@ -2307,6 +2380,185 @@ export function defineScopeDO(modules, bareOps) {
2307
2380
  async systemGrantsStatus() {
2308
2381
  return systemGrantsStatus(this.switchSql(), new Date().toISOString());
2309
2382
  }
2383
+ // -- the rewind hold (#1819) ------------------------------------------------
2384
+ // Two halves. On EVERY scope instance: its identity, and whether it has armed a rewind, so
2385
+ // the host can tell storage the rewind will discard from storage it restored. On ONE object
2386
+ // per deployment, the one named `SWITCH_HOLDS_NAME` and never a scope's own: the claims
2387
+ // themselves, because a rewind replaces the rewound scope's whole storage. The claims table
2388
+ // is created on first use, so no scope ever carries it.
2389
+ /** This instance, and nothing before or after it: a restart is a new id. */
2390
+ instanceId = crypto.randomUUID();
2391
+ /** Whether this instance armed a rewind: then its writes are discarded at the restart. */
2392
+ rewindArmed = false;
2393
+ /** Whether this instance began arming one: set before the restore call, and never cleared. */
2394
+ rewindArming = false;
2395
+ /**
2396
+ * Which instance is serving, and whether it armed a rewind or began to. For an ambiguous
2397
+ * rewind throw: arming counts as armed, because the restore call may yet land.
2398
+ */
2399
+ rewindProbe() {
2400
+ return { instance: this.instanceId, armed: this.rewindArmed || this.rewindArming };
2401
+ }
2402
+ /** Whether this instance has created the claims table; it never goes away once it exists. */
2403
+ switchHoldsReady = false;
2404
+ switchHoldsTable() {
2405
+ if (this.switchHoldsReady)
2406
+ return;
2407
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_holds (
2408
+ scope_id TEXT NOT NULL,
2409
+ module_id TEXT NOT NULL,
2410
+ claim_id TEXT NOT NULL,
2411
+ state TEXT NOT NULL,
2412
+ doomed TEXT,
2413
+ held_at TEXT NOT NULL,
2414
+ PRIMARY KEY (scope_id, module_id, claim_id)
2415
+ )`);
2416
+ // #1839: the ON tombstones, and the one counter that orders them against a rewind's reads.
2417
+ // This object's storage is never rewound, so the counter only ever goes up.
2418
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_hold_ons (
2419
+ scope_id TEXT NOT NULL,
2420
+ module_id TEXT NOT NULL,
2421
+ seq INTEGER NOT NULL,
2422
+ PRIMARY KEY (scope_id, module_id)
2423
+ )`);
2424
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_hold_seq (
2425
+ id INTEGER PRIMARY KEY CHECK (id = 1),
2426
+ seq INTEGER NOT NULL
2427
+ )`);
2428
+ this.switchHoldsReady = true;
2429
+ }
2430
+ /**
2431
+ * #1839: where the ON counter stands now. A rewind reads this BEFORE it reads the scope's status,
2432
+ * and hands it to `switchHoldClaim`: an ON tombstoned after it may have moved after that read.
2433
+ */
2434
+ switchHoldToken() {
2435
+ this.switchHoldsTable();
2436
+ const row = this.sql.exec('SELECT seq FROM _substrat_switch_hold_seq WHERE id = 1').toArray()[0];
2437
+ return row?.seq ?? 0;
2438
+ }
2439
+ /**
2440
+ * #1839: an operator's ON has moved: release the claims it read before the move (S0) on this
2441
+ * module, and tombstone the module with the next count, in one transaction. Called only AFTER the
2442
+ * move, so a rewind whose token predates the tombstone may have read the switch before the ON;
2443
+ * `switchHoldClaim` then refuses that stale read's row. A row it inserted before this ran is
2444
+ * either one of the S0 claims (released here) or a claim created after S0, which is left alone.
2445
+ */
2446
+ switchHoldOn(scopeId, moduleId, claimIds) {
2447
+ this.switchHoldsTable();
2448
+ this.ctx.storage.transactionSync(() => {
2449
+ const seq = this.sql
2450
+ .exec(`INSERT INTO _substrat_switch_hold_seq (id, seq) VALUES (1, 1)
2451
+ ON CONFLICT (id) DO UPDATE SET seq = seq + 1 RETURNING seq`)
2452
+ .toArray()[0].seq;
2453
+ this.sql.exec(`INSERT INTO _substrat_switch_hold_ons (scope_id, module_id, seq) VALUES (?, ?, ?)
2454
+ ON CONFLICT (scope_id, module_id) DO UPDATE SET seq = excluded.seq`, scopeId, moduleId, seq);
2455
+ for (const claimId of claimIds) {
2456
+ this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND module_id = ? AND claim_id = ?', scopeId, moduleId, claimId);
2457
+ }
2458
+ });
2459
+ }
2460
+ /**
2461
+ * One rewind's claim on these modules, `pending` until the rewind has armed. Only the rewind
2462
+ * that owns the claim calls this, and only before it arms. Each new row is stamped with THIS
2463
+ * object's clock, and `switchHoldYoungestMs` measures against the same clock (#1839). A row
2464
+ * already there keeps its stamp.
2465
+ *
2466
+ * `token` is `switchHoldToken` as the rewind read it before the status read these modules came
2467
+ * from. A module tombstoned by an ON after that is skipped: the read may predate the ON, and
2468
+ * the ON is the operator's newer word (#1839).
2469
+ */
2470
+ switchHoldClaim(scopeId, moduleIds, claimId, token) {
2471
+ this.switchHoldsTable();
2472
+ const at = new Date().toISOString();
2473
+ this.ctx.storage.transactionSync(() => {
2474
+ for (const moduleId of moduleIds) {
2475
+ this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
2476
+ SELECT ?, ?, ?, 'pending', NULL, ?
2477
+ WHERE NOT EXISTS (
2478
+ SELECT 1 FROM _substrat_switch_hold_ons WHERE scope_id = ? AND module_id = ? AND seq > ?
2479
+ )`, scopeId, moduleId, claimId, at, scopeId, moduleId, token);
2480
+ }
2481
+ });
2482
+ }
2483
+ /**
2484
+ * #1839: an OFF joins other rewinds' claims. The row takes its claim's state and doomed instance
2485
+ * from the claim's own rows, in this transaction, so it can never be staler than the claim; it
2486
+ * is stamped with this object's clock. A claim with no rows left is not joined: this cannot tell
2487
+ * a claim its rewind dropped (a refusal) from one an ON emptied, and a row created for a dropped
2488
+ * one would hold the module with no rewind behind it.
2489
+ */
2490
+ switchHoldJoin(scopeId, moduleId, claimIds) {
2491
+ this.switchHoldsTable();
2492
+ const at = new Date().toISOString();
2493
+ this.ctx.storage.transactionSync(() => {
2494
+ for (const claimId of claimIds) {
2495
+ this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
2496
+ SELECT scope_id, ?, claim_id, state, doomed, ? FROM _substrat_switch_holds
2497
+ WHERE scope_id = ? AND claim_id = ? LIMIT 1`, moduleId, at, scopeId, claimId);
2498
+ }
2499
+ });
2500
+ }
2501
+ /**
2502
+ * #1839: how long ago, on this object's clock, the youngest row of one claim was stamped; null
2503
+ * once the claim has no rows. The rewind waits on this, so its age and the stamp are read on
2504
+ * one clock and no two clocks are ever compared.
2505
+ */
2506
+ switchHoldYoungestMs(scopeId, claimId) {
2507
+ this.switchHoldsTable();
2508
+ const row = this.sql
2509
+ .exec('SELECT MAX(held_at) AS youngest FROM _substrat_switch_holds WHERE scope_id = ? AND claim_id = ?', scopeId, claimId)
2510
+ .toArray()[0];
2511
+ return row.youngest ? Date.now() - Date.parse(row.youngest) : null;
2512
+ }
2513
+ /** The rewind armed (or may have): its claim is `armed`, naming the instance it doomed. */
2514
+ switchHoldArm(scopeId, claimId, doomed) {
2515
+ this.switchHoldsTable();
2516
+ this.sql.exec(`UPDATE _substrat_switch_holds SET state = 'armed', doomed = ? WHERE scope_id = ? AND claim_id = ?`, doomed, scopeId, claimId);
2517
+ }
2518
+ /** A definite refusal: this rewind's own claim goes, and no other. */
2519
+ switchHoldDrop(scopeId, claimId) {
2520
+ this.switchHoldsTable();
2521
+ this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND claim_id = ?', scopeId, claimId);
2522
+ }
2523
+ /** Every held (scope, module) in the deployment, one read for a whole sweep pass. */
2524
+ switchHoldsAll() {
2525
+ this.switchHoldsTable();
2526
+ return this.sql
2527
+ .exec('SELECT DISTINCT scope_id, module_id FROM _substrat_switch_holds')
2528
+ .toArray()
2529
+ .map((r) => ({ scopeId: r.scope_id, moduleId: r.module_id }));
2530
+ }
2531
+ /**
2532
+ * Every claim row on one scope, for a switch move to read before it moves: the release rule
2533
+ * reads the module's own rows, and an OFF (#1839) the scope's claims it would join.
2534
+ */
2535
+ switchHoldClaims(scopeId) {
2536
+ this.switchHoldsTable();
2537
+ return this.sql
2538
+ .exec('SELECT claim_id, module_id, state, doomed, held_at FROM _substrat_switch_holds WHERE scope_id = ?', scopeId)
2539
+ .toArray()
2540
+ .map((r) => ({
2541
+ claimId: r.claim_id,
2542
+ moduleId: r.module_id,
2543
+ state: r.state,
2544
+ doomed: r.doomed ?? null,
2545
+ heldAt: r.held_at,
2546
+ }));
2547
+ }
2548
+ /** Release these claims on one module, or every claim on the scope when `claimIds` is null. */
2549
+ switchHoldRelease(scopeId, moduleId, claimIds) {
2550
+ this.switchHoldsTable();
2551
+ this.ctx.storage.transactionSync(() => {
2552
+ if (moduleId === null || claimIds === null) {
2553
+ this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ?', scopeId);
2554
+ this.sql.exec('DELETE FROM _substrat_switch_hold_ons WHERE scope_id = ?', scopeId);
2555
+ return;
2556
+ }
2557
+ for (const claimId of claimIds) {
2558
+ this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND module_id = ? AND claim_id = ?', scopeId, moduleId, claimId);
2559
+ }
2560
+ });
2561
+ }
2310
2562
  /**
2311
2563
  * Where every peer this scope holds or has held grants for stands (#1706) — the
2312
2564
  * kernel's `peerGrantsStatus`, over this DO's own storage, and the same plain
@@ -2429,12 +2681,17 @@ export function defineScopeDO(modules, bareOps) {
2429
2681
  * `kind` is LAST because this is a positional RPC — see the interface's own note
2430
2682
  * in `host.ts`. An old DO drops a trailing argument; a leading one it binds, and
2431
2683
  * every value after it lands one column to the left, silently.
2684
+ *
2685
+ * `invocationId` (#1525) is appended after `kind` for the same reason and defaulted
2686
+ * to null, so a coordinator that sends none — every freshness verdict, and every
2687
+ * caller predating this argument — records the honest "no call was carried".
2432
2688
  */
2433
- async recordScheduleRun(unit, at, status, kind) {
2434
- this.sql.exec(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status)
2435
- VALUES (?, ?, ?, ?)
2689
+ async recordScheduleRun(unit, at, status, kind, invocationId) {
2690
+ this.sql.exec(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status, invocation_id)
2691
+ VALUES (?, ?, ?, ?, ?)
2436
2692
  ON CONFLICT(kind, schedule_op) DO UPDATE SET last_run_at = excluded.last_run_at,
2437
- last_status = excluded.last_status`, kind, unit, at, status);
2693
+ last_status = excluded.last_status,
2694
+ invocation_id = excluded.invocation_id`, kind, unit, at, status, invocationId ?? null);
2438
2695
  }
2439
2696
  // -- the resumable-run driver's store (#1577) -----------------------------
2440
2697
  //
@@ -3092,6 +3349,11 @@ export function defineScopeDO(modules, bareOps) {
3092
3349
  // #1237: the invocation column on a DO created before it. Nullable, and the
3093
3350
  // null is honestly "none was carried" for every legacy row.
3094
3351
  'ALTER TABLE _substrat_outbox ADD COLUMN invocation_id TEXT',
3352
+ // #1525: the invocation a fired schedule ran in, on a scope DO created before
3353
+ // the column. Nullable, and the null is honestly "no call was carried" for
3354
+ // every legacy row — a past run's id cannot be recovered afterwards, exactly
3355
+ // as the other #1525 columns above argued.
3356
+ 'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
3095
3357
  ]) {
3096
3358
  try {
3097
3359
  this.sql.exec(alter);
@@ -3142,7 +3404,11 @@ export function defineScopeDO(modules, bareOps) {
3142
3404
  this.sql.exec(stmt);
3143
3405
  });
3144
3406
  }
3145
- async importDump(tables, destScopeId) {
3407
+ async importDump(tables, destScopeId,
3408
+ /** The directory's recorded-off modules (#1742), switched off on `destScopeId` right after
3409
+ * the replay re-points the grants, in the same event: a dump from before the switch was
3410
+ * pulled carries the grants live and no marker. Needs `destScopeId`, the scope restored. */
3411
+ switchOff) {
3146
3412
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
3147
3413
  //
3148
3414
  // Two distinct FK hazards, and both need the deferral — the second is why it cannot
@@ -3161,7 +3427,17 @@ export function defineScopeDO(modules, bareOps) {
3161
3427
  // Deferral holds every check until commit, by which point the old rows are gone and
3162
3428
  // the new ones are all in. It also covers what a topological sort cannot express: FK
3163
3429
  // cycles, and self-referencing rows within a single table.
3164
- await this.ctx.storage.transaction(async () => {
3430
+ // A dump written before indexes were excluded may still carry them; skipped
3431
+ // rather than failing a restore over data about to be recomputed.
3432
+ const replayable = tables.filter((t) => !isSearchIndexTable(t.name));
3433
+ // The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
3434
+ // in the string it is given, so a `ddl` with anything appended to its CREATE
3435
+ // TABLE executed that too — with entirely plain identifiers, which is why no
3436
+ // amount of name checking reaches it. There is no prepare step here to compile
3437
+ // only the first statement, so the text itself has to be the one statement.
3438
+ // Pure input validation, so it runs before the first DROP: a refused dump touches nothing.
3439
+ assertReplayableDump(replayable, { maxColumns: DO_SQL_LIMITS.columns });
3440
+ const switched = await this.ctx.storage.transaction(async () => {
3165
3441
  this.sql.exec('PRAGMA defer_foreign_keys = ON');
3166
3442
  // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
3167
3443
  const existing = this.sql
@@ -3175,15 +3451,6 @@ export function defineScopeDO(modules, bareOps) {
3175
3451
  continue;
3176
3452
  this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
3177
3453
  }
3178
- // A dump written before indexes were excluded may still carry them; skipped
3179
- // rather than failing a restore over data about to be recomputed.
3180
- const replayable = tables.filter((t) => !isSearchIndexTable(t.name));
3181
- // The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
3182
- // in the string it is given, so a `ddl` with anything appended to its CREATE
3183
- // TABLE executed that too — with entirely plain identifiers, which is why no
3184
- // amount of name checking reaches it. There is no prepare step here to compile
3185
- // only the first statement, so the text itself has to be the one statement.
3186
- assertReplayableDump(replayable);
3187
3454
  for (const t of replayable)
3188
3455
  this.sql.exec(t.ddl);
3189
3456
  for (const t of replayable) {
@@ -3195,25 +3462,39 @@ export function defineScopeDO(modules, bareOps) {
3195
3462
  for (const row of t.rows)
3196
3463
  this.sql.exec(insert, ...row);
3197
3464
  }
3465
+ // Re-assert the kernel spine (#321). A dump captured from a WORLD that stores some
3466
+ // `_substrat_*` tables ELSEWHERE carries only a subset — an `@substrat-run/adapter-
3467
+ // sqlite` scope file, for instance, keeps `_substrat_roles`/`_substrat_tenant_tuples`
3468
+ // in its DIRECTORY database, so its per-scope dump omits them. Replaying such a dump
3469
+ // verbatim would leave this DO missing those spine tables, and the very next
3470
+ // permission check would raise a bare `no such table: _substrat_roles`. KERNEL_DDL is
3471
+ // all IF NOT EXISTS, so this recreates only what the dump did not carry (empty), and
3472
+ // never disturbs a table the dump DID bring. Roles land empty here and are re-projected
3473
+ // by the restore's repair leg (host.projectRolesLocal) — the spine's job is only to
3474
+ // exist so the checker can read it.
3475
+ for (const stmt of splitSqlStatements(KERNEL_DDL))
3476
+ this.sql.exec(stmt);
3477
+ // …and the additive columns, for the same reason KERNEL_DDL is re-asserted: a
3478
+ // dump captured before a column existed replays DDL WITHOUT it, and IF NOT
3479
+ // EXISTS cannot widen a table the dump did bring. Without this, the next emit
3480
+ // in this instance fails with `no such column` until a cold start re-runs the
3481
+ // constructor's pass.
3482
+ this.applySpineColumnAdditions();
3483
+ // #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
3484
+ // not this one. Dropped, so a restore never carries a receipt over; the repair
3485
+ // projection that follows writes this scope's own.
3486
+ this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
3487
+ // Re-point the restored grants at THIS scope (after the spine exists, so a dump
3488
+ // that carried no tuples table still finds one here).
3489
+ if (destScopeId)
3490
+ this.rewriteScopeTuples(destScopeId);
3491
+ // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
3492
+ // the spine and the re-point. A switch that throws rolls the whole restore back, so
3493
+ // the dump's grants never commit live without the switch that should cover them.
3494
+ return destScopeId && switchOff
3495
+ ? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
3496
+ : [];
3198
3497
  });
3199
- // Re-assert the kernel spine (#321). A dump captured from a WORLD that stores some
3200
- // `_substrat_*` tables ELSEWHERE carries only a subset — an `@substrat-run/adapter-
3201
- // sqlite` scope file, for instance, keeps `_substrat_roles`/`_substrat_tenant_tuples`
3202
- // in its DIRECTORY database, so its per-scope dump omits them. Replaying such a dump
3203
- // verbatim would leave this DO missing those spine tables, and the very next
3204
- // permission check would raise a bare `no such table: _substrat_roles`. KERNEL_DDL is
3205
- // all IF NOT EXISTS, so this recreates only what the dump did not carry (empty), and
3206
- // never disturbs a table the dump DID bring. Roles land empty here and are re-projected
3207
- // by the restore's repair leg (host.projectRolesLocal) — the spine's job is only to
3208
- // exist so the checker can read it.
3209
- for (const stmt of splitSqlStatements(KERNEL_DDL))
3210
- this.sql.exec(stmt);
3211
- // …and the additive columns, for the same reason KERNEL_DDL is re-asserted: a
3212
- // dump captured before a column existed replays DDL WITHOUT it, and IF NOT
3213
- // EXISTS cannot widen a table the dump did bring. Without this, the next emit
3214
- // in this instance fails with `no such column` until a cold start re-runs the
3215
- // constructor's pass.
3216
- this.applySpineColumnAdditions();
3217
3498
  // Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
3218
3499
  // create, so it also repairs an index a dump left stale, and the triggers it
3219
3500
  // recreates are what keep the restored scope in step from here. Skipped for a plan
@@ -3228,10 +3509,6 @@ export function defineScopeDO(modules, bareOps) {
3228
3509
  for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
3229
3510
  this.sql.exec(stmt);
3230
3511
  }
3231
- // Re-point the restored grants at THIS scope (after the spine exists, so a dump
3232
- // that carried no tuples table still finds one here).
3233
- if (destScopeId)
3234
- this.rewriteScopeTuples(destScopeId);
3235
3512
  // The frontier arrived with the dump — refresh the in-memory applied set so a
3236
3513
  // later migrate() builds on the imported state, not the provisioning state.
3237
3514
  this.applied.clear();
@@ -3263,6 +3540,7 @@ export function defineScopeDO(modules, bareOps) {
3263
3540
  // is complete applies nothing, so there is nothing to report either way.
3264
3541
  this.migrationPromise = undefined;
3265
3542
  this.lastFailure = null;
3543
+ return switched;
3266
3544
  }
3267
3545
  /**
3268
3546
  * Re-point scope-level tuples at the scope they now live in.
@@ -3666,6 +3944,19 @@ export function defineScopeDO(modules, bareOps) {
3666
3944
  principal: capabilityId ? capabilityId : principal,
3667
3945
  sql: guardSecrets(doScopedSql(sql), minted),
3668
3946
  now: () => at,
3947
+ // #1746/#1747: the host stamps who and where; module code supplies only the template
3948
+ // and its fields. A consumer runs under the system override, so it logs as `system`.
3949
+ // The invocation id is read per call, as the DO holds it for the queued body's length.
3950
+ log: moduleLog({
3951
+ tenantId,
3952
+ scopeId,
3953
+ operation: operation ?? null,
3954
+ invocationId: () => this.invocationId,
3955
+ principalKind: systemActor ? 'system' : subject.kind,
3956
+ // The string-safe redaction: `redactSecrets` parses its serialization back, and a
3957
+ // log's text is not JSON.
3958
+ redact: (text) => redactSecretText(text, minted),
3959
+ }),
3669
3960
  emit: (event) => {
3670
3961
  assertImpersonationWrites(impersonation, 'ctx.emit');
3671
3962
  const parsed = domainEventInput.parse(event);
@@ -3797,6 +4088,7 @@ export function defineScopeDO(modules, bareOps) {
3797
4088
  */
3798
4089
  grant: async (principal, permission, entity) => {
3799
4090
  assertImpersonationWrites(impersonation, 'ctx.grant');
4091
+ entityObjectRef(entity, 'ctx.grant'); // #1856: a tuple the walk can read back
3800
4092
  const held = await runCheck(permission, entity);
3801
4093
  if (!held.allowed) {
3802
4094
  throw new PermissionDenied(`cannot grant '${permission}' on ${entity.entityType}:${entity.entityId} — ` +
@@ -3804,6 +4096,11 @@ export function defineScopeDO(modules, bareOps) {
3804
4096
  }
3805
4097
  sql.exec(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)`, `principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`);
3806
4098
  },
4099
+ /**
4100
+ * Deliberately NOT the #1856 grammar check `grant` and `link` make: a revoke writes
4101
+ * nothing the walk must read back, and a grant stored before that check existed must
4102
+ * stay removable.
4103
+ */
3807
4104
  revoke: async (principal, permission, entity) => {
3808
4105
  assertImpersonationWrites(impersonation, 'ctx.revoke');
3809
4106
  const held = await runCheck(permission, entity);
@@ -3832,6 +4129,8 @@ export function defineScopeDO(modules, bareOps) {
3832
4129
  }),
3833
4130
  link: (child, parent) => {
3834
4131
  assertImpersonationWrites(impersonation, 'ctx.link');
4132
+ entityObjectRef(child, 'ctx.link'); // #1856: an edge the walk can read back
4133
+ entityObjectRef(parent, 'ctx.link');
3835
4134
  const allowed = relations.get(child.entityType);
3836
4135
  if (!allowed?.has(parent.entityType)) {
3837
4136
  throw new Error(`undeclared entity relation: ${child.entityType} → ${parent.entityType} ` +
@@ -3989,32 +4288,50 @@ export function defineScopeDO(modules, bareOps) {
3989
4288
  * (a role-only re-projection like the restore repair) leaves projected keys
3990
4289
  * untouched; passing a list — even `[]` — full-replaces them, which is how a
3991
4290
  * revoked connection stops being sealable to. */
3992
- connectionKeys) {
4291
+ connectionKeys,
4292
+ /** The directory's recorded-off modules (#1742), switched off in THIS unit right after the
4293
+ * seat, so no sweep can run the grants the seat just re-created. `scopeId` is the scope
4294
+ * this projection provisions (the object of `scopeTuples`), never another. */
4295
+ switchOff) {
3993
4296
  if (await this.isReaped())
3994
- return;
4297
+ return [];
3995
4298
  // A projection that arrives after this scope was reaped is dropped, not
3996
4299
  // applied: writing it would recreate storage the platform deliberately
3997
4300
  // destroyed. Silent rather than throwing — the fan-out is a best-effort
3998
4301
  // convergence over many scopes and one dead sibling must not fail the others,
3999
4302
  // and a reaped scope converging to "nothing" IS convergence.
4000
- await this.queue.enqueue(() => {
4303
+ // The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
4304
+ // serializes but does not roll back, and a role insert that throws part-way must not leave
4305
+ // the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
4306
+ return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
4307
+ // #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
4308
+ // a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
4309
+ // moves, so a misdirected projection can never re-point a scope or leave its roles behind.
4310
+ // A restore does not trip this: `importDump` drops the dump's receipt, so the repair
4311
+ // projection that follows finds none and writes the destination's own.
4312
+ const held = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
4313
+ if (held && held.value !== tenantId) {
4314
+ throw substratError('conflict', `applyProjection refused: this scope was provisioned for tenant ${held.value}, and a projection for tenant ${tenantId} would re-point it`);
4315
+ }
4316
+ // Written before any guard below can return early, so every projection leaves it.
4317
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('provisioned_for', ?)`, tenantId);
4001
4318
  this.sql.exec(`DELETE FROM _substrat_roles WHERE tenant_id = ?`, tenantId);
4002
4319
  for (const r of roles) {
4003
4320
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, revoked_at)
4004
- VALUES (?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source);
4321
+ VALUES (?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source);
4005
4322
  }
4006
4323
  this.sql.exec(`DELETE FROM _substrat_tenant_tuples WHERE tenant_id = ?`, tenantId);
4007
4324
  for (const t of tuples) {
4008
4325
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
4009
- (tenant_id, subject, relation, object, expires_at, revoked_at)
4010
- VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
4326
+ (tenant_id, subject, relation, object, expires_at, revoked_at)
4327
+ VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
4011
4328
  }
4012
4329
  if (entitlements !== undefined) {
4013
4330
  this.sql.exec(`DELETE FROM _substrat_entitlements WHERE tenant_id = ?`, tenantId);
4014
4331
  for (const e of entitlements) {
4015
4332
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_entitlements
4016
- (tenant_id, entitlement_key, expires_at, quota, plan)
4017
- VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
4333
+ (tenant_id, entitlement_key, expires_at, quota, plan)
4334
+ VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
4018
4335
  }
4019
4336
  // #304: once a scope has been projected WITH entitlements even once, its gate
4020
4337
  // switches from trust-upstream to strict fail-closed (a missing/expired key
@@ -4030,8 +4347,8 @@ export function defineScopeDO(modules, bareOps) {
4030
4347
  this.sql.exec(`DELETE FROM _substrat_identity_links WHERE tenant_id = ?`, tenantId);
4031
4348
  for (const i of identities) {
4032
4349
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_identity_links
4033
- (tenant_id, provider, external_id, principal_id, scope_id)
4034
- VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
4350
+ (tenant_id, provider, external_id, principal_id, scope_id)
4351
+ VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
4035
4352
  }
4036
4353
  }
4037
4354
  // #687: connection sealing keys ride the same snapshot. Full replace, so a
@@ -4041,8 +4358,8 @@ export function defineScopeDO(modules, bareOps) {
4041
4358
  this.sql.exec(`DELETE FROM _substrat_connection_keys WHERE tenant_id = ?`, tenantId);
4042
4359
  for (const k of connectionKeys) {
4043
4360
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_connection_keys
4044
- (tenant_id, connection_id, provider, key_id, public_key)
4045
- VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
4361
+ (tenant_id, connection_id, provider, key_id, public_key)
4362
+ VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
4046
4363
  }
4047
4364
  }
4048
4365
  // #332: scope-level grants (the owner's role tuple at provision) are written in the
@@ -4072,9 +4389,14 @@ export function defineScopeDO(modules, bareOps) {
4072
4389
  if (!st.lockout_reseat)
4073
4390
  continue;
4074
4391
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
4075
- VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
4392
+ VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
4076
4393
  }
4077
4394
  }
4395
+ // #1742: the directory's recorded-off modules go back off HERE, after the seat and in
4396
+ // its unit. A wiped scope's seat has just re-created their `system:` grants (#1659), and
4397
+ // a re-assert arriving later from the control plane left a window in which this
4398
+ // deployment's own sweeper could fire them. Nothing runs between these two lines.
4399
+ const switched = switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
4078
4400
  // #332: only switch on strict local enforcement when SOMEONE actually holds a role.
4079
4401
  // A projection that leaves role definitions but no effective principal→role grant would
4080
4402
  // make every check fail closed — a scope serving nothing but denials, unfixable from
@@ -4083,9 +4405,10 @@ export function defineScopeDO(modules, bareOps) {
4083
4405
  // regardless of this flag, so the owner grant is written above in the same unit — this
4084
4406
  // guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
4085
4407
  if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
4086
- return;
4408
+ return switched;
4087
4409
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
4088
- });
4410
+ return switched;
4411
+ }));
4089
4412
  }
4090
4413
  /**
4091
4414
  * Resolve an external identity from this scope's projected links (#406) — the