@substrat-run/adapter-cloudflare 0.122.1 → 0.124.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
3
  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';
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`).
@@ -900,22 +901,37 @@ export function defineScopeDO(modules, bareOps) {
900
901
  return { consumes, cursors };
901
902
  }
902
903
  /**
903
- * #1705 PR 2: does this DO hold a scope PROVISIONED here for `tenantId`?
904
+ * #1705 PR 2 / #1738: does this DO hold a scope PROVISIONED here for `tenantId`?
904
905
  *
905
906
  * 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.
907
+ * have is what provisioning wrote. A scope this deployment never provisioned has none of
908
+ * it: its DO is empty, and a cross-vertical verb answering from it is a wrong answer rather
909
+ * than a failure: a watermark of "never read", or a delivery journaled into a scope that is
910
+ * not the install.
911
+ *
912
+ * The answer is the `provisioned_for` RECEIPT `applyProjection` writes (#1738): the tenant
913
+ * this scope was provisioned for, stated rather than inferred. A receipt decides alone, so
914
+ * a scope holding a receipt for ANOTHER tenant is refused whatever role rows sit in it (K-3's
915
+ * pair check), and a stray role row can no longer make a scope serve a tenant it was never
916
+ * provisioned for.
917
+ *
918
+ * A scope with NO receipt was provisioned before it existed, or has just been restored (a
919
+ * dump's receipt is dropped on import: it describes the scope the dump came from). Only
920
+ * then is the old inference used: `provisionScopeLocal` projected role definitions under
921
+ * the tenant, so a `_substrat_roles` row for it says the same thing. The next projection
922
+ * (reconcile, or the restore's repair) writes the receipt, after which the inference is
923
+ * never consulted again.
913
924
  *
914
925
  * Read without migrating, so asking about a foreign scope leaves its DO as empty as it found it.
915
926
  */
916
927
  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)
928
+ const has = (table) => this.sql.exec(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, table).toArray().length > 0;
929
+ if (has('_substrat_meta')) {
930
+ const receipt = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
931
+ if (receipt)
932
+ return receipt.value === tenantId;
933
+ }
934
+ if (!has('_substrat_roles'))
919
935
  return false;
920
936
  return this.sql.exec('SELECT 1 FROM _substrat_roles WHERE tenant_id = ? LIMIT 1', tenantId).toArray().length > 0;
921
937
  }
@@ -1174,26 +1190,34 @@ export function defineScopeDO(modules, bareOps) {
1174
1190
  async rewindToBookmark(bookmark, opts) {
1175
1191
  const storage = this.ctx.storage;
1176
1192
  if (typeof storage.onNextSessionRestoreBookmark !== 'function') {
1177
- throw new Error('point-in-time rewind is not available on this host (PITR is production-plane only)');
1193
+ throw new Error(`${REWIND_REFUSED}point-in-time rewind is not available on this host (PITR is production-plane only)`);
1178
1194
  }
1179
1195
  const row = this.sql
1180
1196
  .exec('SELECT taken_at FROM _substrat_migration_bookmarks WHERE bookmark = ?', bookmark)
1181
1197
  .toArray()[0];
1182
1198
  if (!opts?.force) {
1183
1199
  if (!row) {
1184
- throw new Error('unknown bookmark — not one this scope recorded before a migration (force admits any bookmark Cloudflare holds)');
1200
+ throw new Error(`${REWIND_REFUSED}unknown bookmark — not one this scope recorded before a migration ` +
1201
+ '(force admits any bookmark Cloudflare holds)');
1185
1202
  }
1186
1203
  const ageMs = Date.now() - Date.parse(row.taken_at);
1187
1204
  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; ` +
1205
+ throw new Error(`${REWIND_REFUSED}bookmark is ${Math.round(ageMs / 3_600_000)}h old — rewinding discards EVERY write since; ` +
1189
1206
  `use the backup restore path (#278), or force if the loss is intended`);
1190
1207
  }
1191
1208
  }
1209
+ // #1819: arming from BEFORE the call, so a probe that lands while it is in flight answers
1210
+ // armed. Left set if the call throws: that is not a refusal, and it may have armed.
1211
+ this.rewindArming = true;
1192
1212
  const confirmed = await storage.onNextSessionRestoreBookmark(bookmark);
1213
+ // #1819: from here, everything this instance writes is discarded at the restart.
1214
+ this.rewindArmed = true;
1193
1215
  // Answer first, then restart to complete the restore — an immediate abort
1194
1216
  // would take the RPC response down with it.
1195
1217
  setTimeout(() => this.ctx.abort(), 100);
1196
- return { rewindingTo: confirmed ?? bookmark };
1218
+ // #1819: which instance is doomed, so the host can tell a switch move made here from one
1219
+ // made on the restored storage after the restart.
1220
+ return { rewindingTo: confirmed ?? bookmark, instance: this.instanceId };
1197
1221
  }
1198
1222
  /**
1199
1223
  * The scope's live `connection:<id>` grant tuples (#726 gap 1) — the read half of
@@ -1249,6 +1273,21 @@ export function defineScopeDO(modules, bareOps) {
1249
1273
  this.sql.exec(seat.sql, ...seat.params);
1250
1274
  });
1251
1275
  }
1276
+ /**
1277
+ * Provisioning's seat of many tuples as ONE unit, then the directory's recorded-off
1278
+ * modules switched off in it (#1742) — the CP-full mirror of what `applyProjection` does
1279
+ * for a CP-less provision. One tuple per `seatTuple` call left a window between the seat
1280
+ * and the re-assert in which a sweep could run the grants just seated.
1281
+ */
1282
+ async seatTuples(tuples, switchOff) {
1283
+ return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
1284
+ for (const t of tuples) {
1285
+ const seat = seatScopeTuple(t.subject, t.relation, t.object, t.expires_at);
1286
+ this.sql.exec(seat.sql, ...seat.params);
1287
+ }
1288
+ return switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
1289
+ }));
1290
+ }
1252
1291
  /**
1253
1292
  * The dispatch capability's admission (#726 remedy B): this delivery may read the
1254
1293
  * attachments of the entity its own spine row names, and no others.
@@ -1508,6 +1547,12 @@ export function defineScopeDO(modules, bareOps) {
1508
1547
  // that exports something, and never for a read-only session, which commits nothing.
1509
1548
  const exportTypes = impersonation?.mode === 'read-only' ? [] : this.crossVertical.exportTypes();
1510
1549
  const exportMark = exportTypes.length > 0 ? this.outboxMark() : null;
1550
+ // #1746: the same mark, when the caller asked what this call emitted. The callback
1551
+ // itself never runs here — its presence is the request, and the report travels
1552
+ // back in the envelope for the coordinator to deliver.
1553
+ const emittedMark = invokeOptions?.onEmitted !== undefined && impersonation?.mode !== 'read-only'
1554
+ ? (exportMark ?? this.outboxMark())
1555
+ : null;
1511
1556
  let result;
1512
1557
  let committedVersion = null;
1513
1558
  // #116: set when this invocation was answered from a recording rather
@@ -1641,6 +1686,13 @@ export function defineScopeDO(modules, bareOps) {
1641
1686
  // own retry backstop, which is what that backstop is for.
1642
1687
  // #1525: still inside the queued body that set it, so these deliveries are this
1643
1688
  // call's own work — the same tail its consumers' emits are stamped in.
1689
+ // #1746: read BEFORE `settleCommitted` drains the consumers, so the rows above the
1690
+ // mark are this operation's own emits. A replay committed nothing.
1691
+ let emitted;
1692
+ if (emittedMark !== null && !replayed) {
1693
+ const q = emittedSinceQuery(emittedMark, EMITTED_REPORT_CAP);
1694
+ emitted = emittedReportOf(this.sql.exec(q.sql, ...q.params).toArray());
1695
+ }
1644
1696
  if (!replayed)
1645
1697
  await this.settleCommitted(tenantId, scopeId, liveSince, this.invocationId);
1646
1698
  // After the tail, so an exported type a consumer emitted in it counts too.
@@ -1651,6 +1703,7 @@ export function defineScopeDO(modules, bareOps) {
1651
1703
  // #1705 PR 2: the coordinator fires `onExportedEvents` from this. Omitted at 0, so
1652
1704
  // the envelope of a deployment that exports nothing is byte-for-byte what it was.
1653
1705
  ...(exported > 0 ? { exported } : {}),
1706
+ ...(emitted ? { emitted } : {}),
1654
1707
  ...(impersonation ? { impersonation: { honoured: true } } : {}),
1655
1708
  // #1672: the acknowledgement the coordinator's skew check reads — see `invoke`.
1656
1709
  ...(capabilitySession !== undefined ? { capability: { honoured: true } } : {}),
@@ -2262,7 +2315,9 @@ export function defineScopeDO(modules, bareOps) {
2262
2315
  * as one `transactionSync`, so its reads and writes are one unit.
2263
2316
  */
2264
2317
  async switchSystemSchedules(moduleId, scopeId, to, at) {
2265
- return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2318
+ const outcome = await this.queue.enqueue(() => this.ctx.storage.transactionSync(() => switchSystemSchedules(this.switchSql(), { moduleId, scopeId, to, at })));
2319
+ // #1819: the instance that applied it, which the rewind hold's release rule reads.
2320
+ return { ...outcome, instance: this.instanceId };
2266
2321
  }
2267
2322
  /**
2268
2323
  * The replay lever on this scope (#1705 PR 3): the kernel's `moveImportCursor`, queued with
@@ -2307,6 +2362,185 @@ export function defineScopeDO(modules, bareOps) {
2307
2362
  async systemGrantsStatus() {
2308
2363
  return systemGrantsStatus(this.switchSql(), new Date().toISOString());
2309
2364
  }
2365
+ // -- the rewind hold (#1819) ------------------------------------------------
2366
+ // Two halves. On EVERY scope instance: its identity, and whether it has armed a rewind, so
2367
+ // the host can tell storage the rewind will discard from storage it restored. On ONE object
2368
+ // per deployment, the one named `SWITCH_HOLDS_NAME` and never a scope's own: the claims
2369
+ // themselves, because a rewind replaces the rewound scope's whole storage. The claims table
2370
+ // is created on first use, so no scope ever carries it.
2371
+ /** This instance, and nothing before or after it: a restart is a new id. */
2372
+ instanceId = crypto.randomUUID();
2373
+ /** Whether this instance armed a rewind: then its writes are discarded at the restart. */
2374
+ rewindArmed = false;
2375
+ /** Whether this instance began arming one: set before the restore call, and never cleared. */
2376
+ rewindArming = false;
2377
+ /**
2378
+ * Which instance is serving, and whether it armed a rewind or began to. For an ambiguous
2379
+ * rewind throw: arming counts as armed, because the restore call may yet land.
2380
+ */
2381
+ rewindProbe() {
2382
+ return { instance: this.instanceId, armed: this.rewindArmed || this.rewindArming };
2383
+ }
2384
+ /** Whether this instance has created the claims table; it never goes away once it exists. */
2385
+ switchHoldsReady = false;
2386
+ switchHoldsTable() {
2387
+ if (this.switchHoldsReady)
2388
+ return;
2389
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_holds (
2390
+ scope_id TEXT NOT NULL,
2391
+ module_id TEXT NOT NULL,
2392
+ claim_id TEXT NOT NULL,
2393
+ state TEXT NOT NULL,
2394
+ doomed TEXT,
2395
+ held_at TEXT NOT NULL,
2396
+ PRIMARY KEY (scope_id, module_id, claim_id)
2397
+ )`);
2398
+ // #1839: the ON tombstones, and the one counter that orders them against a rewind's reads.
2399
+ // This object's storage is never rewound, so the counter only ever goes up.
2400
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_hold_ons (
2401
+ scope_id TEXT NOT NULL,
2402
+ module_id TEXT NOT NULL,
2403
+ seq INTEGER NOT NULL,
2404
+ PRIMARY KEY (scope_id, module_id)
2405
+ )`);
2406
+ this.sql.exec(`CREATE TABLE IF NOT EXISTS _substrat_switch_hold_seq (
2407
+ id INTEGER PRIMARY KEY CHECK (id = 1),
2408
+ seq INTEGER NOT NULL
2409
+ )`);
2410
+ this.switchHoldsReady = true;
2411
+ }
2412
+ /**
2413
+ * #1839: where the ON counter stands now. A rewind reads this BEFORE it reads the scope's status,
2414
+ * and hands it to `switchHoldClaim`: an ON tombstoned after it may have moved after that read.
2415
+ */
2416
+ switchHoldToken() {
2417
+ this.switchHoldsTable();
2418
+ const row = this.sql.exec('SELECT seq FROM _substrat_switch_hold_seq WHERE id = 1').toArray()[0];
2419
+ return row?.seq ?? 0;
2420
+ }
2421
+ /**
2422
+ * #1839: an operator's ON has moved: release the claims it read before the move (S0) on this
2423
+ * module, and tombstone the module with the next count, in one transaction. Called only AFTER the
2424
+ * move, so a rewind whose token predates the tombstone may have read the switch before the ON;
2425
+ * `switchHoldClaim` then refuses that stale read's row. A row it inserted before this ran is
2426
+ * either one of the S0 claims (released here) or a claim created after S0, which is left alone.
2427
+ */
2428
+ switchHoldOn(scopeId, moduleId, claimIds) {
2429
+ this.switchHoldsTable();
2430
+ this.ctx.storage.transactionSync(() => {
2431
+ const seq = this.sql
2432
+ .exec(`INSERT INTO _substrat_switch_hold_seq (id, seq) VALUES (1, 1)
2433
+ ON CONFLICT (id) DO UPDATE SET seq = seq + 1 RETURNING seq`)
2434
+ .toArray()[0].seq;
2435
+ this.sql.exec(`INSERT INTO _substrat_switch_hold_ons (scope_id, module_id, seq) VALUES (?, ?, ?)
2436
+ ON CONFLICT (scope_id, module_id) DO UPDATE SET seq = excluded.seq`, scopeId, moduleId, seq);
2437
+ for (const claimId of claimIds) {
2438
+ this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND module_id = ? AND claim_id = ?', scopeId, moduleId, claimId);
2439
+ }
2440
+ });
2441
+ }
2442
+ /**
2443
+ * One rewind's claim on these modules, `pending` until the rewind has armed. Only the rewind
2444
+ * that owns the claim calls this, and only before it arms. Each new row is stamped with THIS
2445
+ * object's clock, and `switchHoldYoungestMs` measures against the same clock (#1839). A row
2446
+ * already there keeps its stamp.
2447
+ *
2448
+ * `token` is `switchHoldToken` as the rewind read it before the status read these modules came
2449
+ * from. A module tombstoned by an ON after that is skipped: the read may predate the ON, and
2450
+ * the ON is the operator's newer word (#1839).
2451
+ */
2452
+ switchHoldClaim(scopeId, moduleIds, claimId, token) {
2453
+ this.switchHoldsTable();
2454
+ const at = new Date().toISOString();
2455
+ this.ctx.storage.transactionSync(() => {
2456
+ for (const moduleId of moduleIds) {
2457
+ this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
2458
+ SELECT ?, ?, ?, 'pending', NULL, ?
2459
+ WHERE NOT EXISTS (
2460
+ SELECT 1 FROM _substrat_switch_hold_ons WHERE scope_id = ? AND module_id = ? AND seq > ?
2461
+ )`, scopeId, moduleId, claimId, at, scopeId, moduleId, token);
2462
+ }
2463
+ });
2464
+ }
2465
+ /**
2466
+ * #1839: an OFF joins other rewinds' claims. The row takes its claim's state and doomed instance
2467
+ * from the claim's own rows, in this transaction, so it can never be staler than the claim; it
2468
+ * is stamped with this object's clock. A claim with no rows left is not joined: this cannot tell
2469
+ * a claim its rewind dropped (a refusal) from one an ON emptied, and a row created for a dropped
2470
+ * one would hold the module with no rewind behind it.
2471
+ */
2472
+ switchHoldJoin(scopeId, moduleId, claimIds) {
2473
+ this.switchHoldsTable();
2474
+ const at = new Date().toISOString();
2475
+ this.ctx.storage.transactionSync(() => {
2476
+ for (const claimId of claimIds) {
2477
+ this.sql.exec(`INSERT OR IGNORE INTO _substrat_switch_holds (scope_id, module_id, claim_id, state, doomed, held_at)
2478
+ SELECT scope_id, ?, claim_id, state, doomed, ? FROM _substrat_switch_holds
2479
+ WHERE scope_id = ? AND claim_id = ? LIMIT 1`, moduleId, at, scopeId, claimId);
2480
+ }
2481
+ });
2482
+ }
2483
+ /**
2484
+ * #1839: how long ago, on this object's clock, the youngest row of one claim was stamped; null
2485
+ * once the claim has no rows. The rewind waits on this, so its age and the stamp are read on
2486
+ * one clock and no two clocks are ever compared.
2487
+ */
2488
+ switchHoldYoungestMs(scopeId, claimId) {
2489
+ this.switchHoldsTable();
2490
+ const row = this.sql
2491
+ .exec('SELECT MAX(held_at) AS youngest FROM _substrat_switch_holds WHERE scope_id = ? AND claim_id = ?', scopeId, claimId)
2492
+ .toArray()[0];
2493
+ return row.youngest ? Date.now() - Date.parse(row.youngest) : null;
2494
+ }
2495
+ /** The rewind armed (or may have): its claim is `armed`, naming the instance it doomed. */
2496
+ switchHoldArm(scopeId, claimId, doomed) {
2497
+ this.switchHoldsTable();
2498
+ this.sql.exec(`UPDATE _substrat_switch_holds SET state = 'armed', doomed = ? WHERE scope_id = ? AND claim_id = ?`, doomed, scopeId, claimId);
2499
+ }
2500
+ /** A definite refusal: this rewind's own claim goes, and no other. */
2501
+ switchHoldDrop(scopeId, claimId) {
2502
+ this.switchHoldsTable();
2503
+ this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND claim_id = ?', scopeId, claimId);
2504
+ }
2505
+ /** Every held (scope, module) in the deployment, one read for a whole sweep pass. */
2506
+ switchHoldsAll() {
2507
+ this.switchHoldsTable();
2508
+ return this.sql
2509
+ .exec('SELECT DISTINCT scope_id, module_id FROM _substrat_switch_holds')
2510
+ .toArray()
2511
+ .map((r) => ({ scopeId: r.scope_id, moduleId: r.module_id }));
2512
+ }
2513
+ /**
2514
+ * Every claim row on one scope, for a switch move to read before it moves: the release rule
2515
+ * reads the module's own rows, and an OFF (#1839) the scope's claims it would join.
2516
+ */
2517
+ switchHoldClaims(scopeId) {
2518
+ this.switchHoldsTable();
2519
+ return this.sql
2520
+ .exec('SELECT claim_id, module_id, state, doomed, held_at FROM _substrat_switch_holds WHERE scope_id = ?', scopeId)
2521
+ .toArray()
2522
+ .map((r) => ({
2523
+ claimId: r.claim_id,
2524
+ moduleId: r.module_id,
2525
+ state: r.state,
2526
+ doomed: r.doomed ?? null,
2527
+ heldAt: r.held_at,
2528
+ }));
2529
+ }
2530
+ /** Release these claims on one module, or every claim on the scope when `claimIds` is null. */
2531
+ switchHoldRelease(scopeId, moduleId, claimIds) {
2532
+ this.switchHoldsTable();
2533
+ this.ctx.storage.transactionSync(() => {
2534
+ if (moduleId === null || claimIds === null) {
2535
+ this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ?', scopeId);
2536
+ this.sql.exec('DELETE FROM _substrat_switch_hold_ons WHERE scope_id = ?', scopeId);
2537
+ return;
2538
+ }
2539
+ for (const claimId of claimIds) {
2540
+ this.sql.exec('DELETE FROM _substrat_switch_holds WHERE scope_id = ? AND module_id = ? AND claim_id = ?', scopeId, moduleId, claimId);
2541
+ }
2542
+ });
2543
+ }
2310
2544
  /**
2311
2545
  * Where every peer this scope holds or has held grants for stands (#1706) — the
2312
2546
  * kernel's `peerGrantsStatus`, over this DO's own storage, and the same plain
@@ -2429,12 +2663,17 @@ export function defineScopeDO(modules, bareOps) {
2429
2663
  * `kind` is LAST because this is a positional RPC — see the interface's own note
2430
2664
  * in `host.ts`. An old DO drops a trailing argument; a leading one it binds, and
2431
2665
  * every value after it lands one column to the left, silently.
2666
+ *
2667
+ * `invocationId` (#1525) is appended after `kind` for the same reason and defaulted
2668
+ * to null, so a coordinator that sends none — every freshness verdict, and every
2669
+ * caller predating this argument — records the honest "no call was carried".
2432
2670
  */
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 (?, ?, ?, ?)
2671
+ async recordScheduleRun(unit, at, status, kind, invocationId) {
2672
+ this.sql.exec(`INSERT INTO _substrat_schedule_state (kind, schedule_op, last_run_at, last_status, invocation_id)
2673
+ VALUES (?, ?, ?, ?, ?)
2436
2674
  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);
2675
+ last_status = excluded.last_status,
2676
+ invocation_id = excluded.invocation_id`, kind, unit, at, status, invocationId ?? null);
2438
2677
  }
2439
2678
  // -- the resumable-run driver's store (#1577) -----------------------------
2440
2679
  //
@@ -3092,6 +3331,11 @@ export function defineScopeDO(modules, bareOps) {
3092
3331
  // #1237: the invocation column on a DO created before it. Nullable, and the
3093
3332
  // null is honestly "none was carried" for every legacy row.
3094
3333
  'ALTER TABLE _substrat_outbox ADD COLUMN invocation_id TEXT',
3334
+ // #1525: the invocation a fired schedule ran in, on a scope DO created before
3335
+ // the column. Nullable, and the null is honestly "no call was carried" for
3336
+ // every legacy row — a past run's id cannot be recovered afterwards, exactly
3337
+ // as the other #1525 columns above argued.
3338
+ 'ALTER TABLE _substrat_schedule_state ADD COLUMN invocation_id TEXT',
3095
3339
  ]) {
3096
3340
  try {
3097
3341
  this.sql.exec(alter);
@@ -3142,7 +3386,11 @@ export function defineScopeDO(modules, bareOps) {
3142
3386
  this.sql.exec(stmt);
3143
3387
  });
3144
3388
  }
3145
- async importDump(tables, destScopeId) {
3389
+ async importDump(tables, destScopeId,
3390
+ /** The directory's recorded-off modules (#1742), switched off on `destScopeId` right after
3391
+ * the replay re-points the grants, in the same event: a dump from before the switch was
3392
+ * pulled carries the grants live and no marker. Needs `destScopeId`, the scope restored. */
3393
+ switchOff) {
3146
3394
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
3147
3395
  //
3148
3396
  // Two distinct FK hazards, and both need the deferral — the second is why it cannot
@@ -3161,7 +3409,17 @@ export function defineScopeDO(modules, bareOps) {
3161
3409
  // Deferral holds every check until commit, by which point the old rows are gone and
3162
3410
  // the new ones are all in. It also covers what a topological sort cannot express: FK
3163
3411
  // cycles, and self-referencing rows within a single table.
3164
- await this.ctx.storage.transaction(async () => {
3412
+ // A dump written before indexes were excluded may still carry them; skipped
3413
+ // rather than failing a restore over data about to be recomputed.
3414
+ const replayable = tables.filter((t) => !isSearchIndexTable(t.name));
3415
+ // The dump is untrusted input (#1143). `SqlStorage.exec` runs every statement
3416
+ // in the string it is given, so a `ddl` with anything appended to its CREATE
3417
+ // TABLE executed that too — with entirely plain identifiers, which is why no
3418
+ // amount of name checking reaches it. There is no prepare step here to compile
3419
+ // only the first statement, so the text itself has to be the one statement.
3420
+ // Pure input validation, so it runs before the first DROP: a refused dump touches nothing.
3421
+ assertReplayableDump(replayable, { maxColumns: DO_SQL_LIMITS.columns });
3422
+ const switched = await this.ctx.storage.transaction(async () => {
3165
3423
  this.sql.exec('PRAGMA defer_foreign_keys = ON');
3166
3424
  // Real tables only; `sqlite_*` internals are auto-managed and un-droppable.
3167
3425
  const existing = this.sql
@@ -3175,15 +3433,6 @@ export function defineScopeDO(modules, bareOps) {
3175
3433
  continue;
3176
3434
  this.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
3177
3435
  }
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
3436
  for (const t of replayable)
3188
3437
  this.sql.exec(t.ddl);
3189
3438
  for (const t of replayable) {
@@ -3195,25 +3444,39 @@ export function defineScopeDO(modules, bareOps) {
3195
3444
  for (const row of t.rows)
3196
3445
  this.sql.exec(insert, ...row);
3197
3446
  }
3447
+ // Re-assert the kernel spine (#321). A dump captured from a WORLD that stores some
3448
+ // `_substrat_*` tables ELSEWHERE carries only a subset — an `@substrat-run/adapter-
3449
+ // sqlite` scope file, for instance, keeps `_substrat_roles`/`_substrat_tenant_tuples`
3450
+ // in its DIRECTORY database, so its per-scope dump omits them. Replaying such a dump
3451
+ // verbatim would leave this DO missing those spine tables, and the very next
3452
+ // permission check would raise a bare `no such table: _substrat_roles`. KERNEL_DDL is
3453
+ // all IF NOT EXISTS, so this recreates only what the dump did not carry (empty), and
3454
+ // never disturbs a table the dump DID bring. Roles land empty here and are re-projected
3455
+ // by the restore's repair leg (host.projectRolesLocal) — the spine's job is only to
3456
+ // exist so the checker can read it.
3457
+ for (const stmt of splitSqlStatements(KERNEL_DDL))
3458
+ this.sql.exec(stmt);
3459
+ // …and the additive columns, for the same reason KERNEL_DDL is re-asserted: a
3460
+ // dump captured before a column existed replays DDL WITHOUT it, and IF NOT
3461
+ // EXISTS cannot widen a table the dump did bring. Without this, the next emit
3462
+ // in this instance fails with `no such column` until a cold start re-runs the
3463
+ // constructor's pass.
3464
+ this.applySpineColumnAdditions();
3465
+ // #1738: a dump's `provisioned_for` names the scope (and tenant) it was captured from,
3466
+ // not this one. Dropped, so a restore never carries a receipt over; the repair
3467
+ // projection that follows writes this scope's own.
3468
+ this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
3469
+ // Re-point the restored grants at THIS scope (after the spine exists, so a dump
3470
+ // that carried no tuples table still finds one here).
3471
+ if (destScopeId)
3472
+ this.rewriteScopeTuples(destScopeId);
3473
+ // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
3474
+ // the spine and the re-point. A switch that throws rolls the whole restore back, so
3475
+ // the dump's grants never commit live without the switch that should cover them.
3476
+ return destScopeId && switchOff
3477
+ ? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
3478
+ : [];
3198
3479
  });
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
3480
  // Rebuild the derived search indexes over the rows just loaded (#827). Drop-then-
3218
3481
  // create, so it also repairs an index a dump left stale, and the triggers it
3219
3482
  // recreates are what keep the restored scope in step from here. Skipped for a plan
@@ -3228,10 +3491,6 @@ export function defineScopeDO(modules, bareOps) {
3228
3491
  for (const stmt of splitSqlStatements(searchIndexDdl(plan)))
3229
3492
  this.sql.exec(stmt);
3230
3493
  }
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
3494
  // The frontier arrived with the dump — refresh the in-memory applied set so a
3236
3495
  // later migrate() builds on the imported state, not the provisioning state.
3237
3496
  this.applied.clear();
@@ -3263,6 +3522,7 @@ export function defineScopeDO(modules, bareOps) {
3263
3522
  // is complete applies nothing, so there is nothing to report either way.
3264
3523
  this.migrationPromise = undefined;
3265
3524
  this.lastFailure = null;
3525
+ return switched;
3266
3526
  }
3267
3527
  /**
3268
3528
  * Re-point scope-level tuples at the scope they now live in.
@@ -3666,6 +3926,19 @@ export function defineScopeDO(modules, bareOps) {
3666
3926
  principal: capabilityId ? capabilityId : principal,
3667
3927
  sql: guardSecrets(doScopedSql(sql), minted),
3668
3928
  now: () => at,
3929
+ // #1746/#1747: the host stamps who and where; module code supplies only the template
3930
+ // and its fields. A consumer runs under the system override, so it logs as `system`.
3931
+ // The invocation id is read per call, as the DO holds it for the queued body's length.
3932
+ log: moduleLog({
3933
+ tenantId,
3934
+ scopeId,
3935
+ operation: operation ?? null,
3936
+ invocationId: () => this.invocationId,
3937
+ principalKind: systemActor ? 'system' : subject.kind,
3938
+ // The string-safe redaction: `redactSecrets` parses its serialization back, and a
3939
+ // log's text is not JSON.
3940
+ redact: (text) => redactSecretText(text, minted),
3941
+ }),
3669
3942
  emit: (event) => {
3670
3943
  assertImpersonationWrites(impersonation, 'ctx.emit');
3671
3944
  const parsed = domainEventInput.parse(event);
@@ -3989,32 +4262,50 @@ export function defineScopeDO(modules, bareOps) {
3989
4262
  * (a role-only re-projection like the restore repair) leaves projected keys
3990
4263
  * untouched; passing a list — even `[]` — full-replaces them, which is how a
3991
4264
  * revoked connection stops being sealable to. */
3992
- connectionKeys) {
4265
+ connectionKeys,
4266
+ /** The directory's recorded-off modules (#1742), switched off in THIS unit right after the
4267
+ * seat, so no sweep can run the grants the seat just re-created. `scopeId` is the scope
4268
+ * this projection provisions (the object of `scopeTuples`), never another. */
4269
+ switchOff) {
3993
4270
  if (await this.isReaped())
3994
- return;
4271
+ return [];
3995
4272
  // A projection that arrives after this scope was reaped is dropped, not
3996
4273
  // applied: writing it would recreate storage the platform deliberately
3997
4274
  // destroyed. Silent rather than throwing — the fan-out is a best-effort
3998
4275
  // convergence over many scopes and one dead sibling must not fail the others,
3999
4276
  // and a reaped scope converging to "nothing" IS convergence.
4000
- await this.queue.enqueue(() => {
4277
+ // The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
4278
+ // serializes but does not roll back, and a role insert that throws part-way must not leave
4279
+ // the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
4280
+ return this.queue.enqueue(() => this.ctx.storage.transactionSync(() => {
4281
+ // #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
4282
+ // a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
4283
+ // moves, so a misdirected projection can never re-point a scope or leave its roles behind.
4284
+ // A restore does not trip this: `importDump` drops the dump's receipt, so the repair
4285
+ // projection that follows finds none and writes the destination's own.
4286
+ const held = this.sql.exec(`SELECT value FROM _substrat_meta WHERE key = 'provisioned_for'`).toArray()[0];
4287
+ if (held && held.value !== tenantId) {
4288
+ throw substratError('conflict', `applyProjection refused: this scope was provisioned for tenant ${held.value}, and a projection for tenant ${tenantId} would re-point it`);
4289
+ }
4290
+ // Written before any guard below can return early, so every projection leaves it.
4291
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('provisioned_for', ?)`, tenantId);
4001
4292
  this.sql.exec(`DELETE FROM _substrat_roles WHERE tenant_id = ?`, tenantId);
4002
4293
  for (const r of roles) {
4003
4294
  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);
4295
+ VALUES (?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source);
4005
4296
  }
4006
4297
  this.sql.exec(`DELETE FROM _substrat_tenant_tuples WHERE tenant_id = ?`, tenantId);
4007
4298
  for (const t of tuples) {
4008
4299
  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);
4300
+ (tenant_id, subject, relation, object, expires_at, revoked_at)
4301
+ VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
4011
4302
  }
4012
4303
  if (entitlements !== undefined) {
4013
4304
  this.sql.exec(`DELETE FROM _substrat_entitlements WHERE tenant_id = ?`, tenantId);
4014
4305
  for (const e of entitlements) {
4015
4306
  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);
4307
+ (tenant_id, entitlement_key, expires_at, quota, plan)
4308
+ VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
4018
4309
  }
4019
4310
  // #304: once a scope has been projected WITH entitlements even once, its gate
4020
4311
  // switches from trust-upstream to strict fail-closed (a missing/expired key
@@ -4030,8 +4321,8 @@ export function defineScopeDO(modules, bareOps) {
4030
4321
  this.sql.exec(`DELETE FROM _substrat_identity_links WHERE tenant_id = ?`, tenantId);
4031
4322
  for (const i of identities) {
4032
4323
  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);
4324
+ (tenant_id, provider, external_id, principal_id, scope_id)
4325
+ VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
4035
4326
  }
4036
4327
  }
4037
4328
  // #687: connection sealing keys ride the same snapshot. Full replace, so a
@@ -4041,8 +4332,8 @@ export function defineScopeDO(modules, bareOps) {
4041
4332
  this.sql.exec(`DELETE FROM _substrat_connection_keys WHERE tenant_id = ?`, tenantId);
4042
4333
  for (const k of connectionKeys) {
4043
4334
  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);
4335
+ (tenant_id, connection_id, provider, key_id, public_key)
4336
+ VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
4046
4337
  }
4047
4338
  }
4048
4339
  // #332: scope-level grants (the owner's role tuple at provision) are written in the
@@ -4072,9 +4363,14 @@ export function defineScopeDO(modules, bareOps) {
4072
4363
  if (!st.lockout_reseat)
4073
4364
  continue;
4074
4365
  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);
4366
+ VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
4076
4367
  }
4077
4368
  }
4369
+ // #1742: the directory's recorded-off modules go back off HERE, after the seat and in
4370
+ // its unit. A wiped scope's seat has just re-created their `system:` grants (#1659), and
4371
+ // a re-assert arriving later from the control plane left a window in which this
4372
+ // deployment's own sweeper could fire them. Nothing runs between these two lines.
4373
+ const switched = switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
4078
4374
  // #332: only switch on strict local enforcement when SOMEONE actually holds a role.
4079
4375
  // A projection that leaves role definitions but no effective principal→role grant would
4080
4376
  // make every check fail closed — a scope serving nothing but denials, unfixable from
@@ -4083,9 +4379,10 @@ export function defineScopeDO(modules, bareOps) {
4083
4379
  // regardless of this flag, so the owner grant is written above in the same unit — this
4084
4380
  // guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
4085
4381
  if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
4086
- return;
4382
+ return switched;
4087
4383
  this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
4088
- });
4384
+ return switched;
4385
+ }));
4089
4386
  }
4090
4387
  /**
4091
4388
  * Resolve an external identity from this scope's projected links (#406) — the