@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/host.js CHANGED
@@ -1,6 +1,7 @@
1
- import { fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, } from '@substrat-run/contracts';
1
+ import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
2
+ import { delegatedReadParams, fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, } from '@substrat-run/contracts';
2
3
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
3
- import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, withRecorded, systemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, } from '@substrat-run/kernel';
4
+ import { attachmentBlobKey, entitlementDenial, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, } from '@substrat-run/kernel';
4
5
  import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
5
6
  import { tenantStoreDatabaseName } from './d1.js';
6
7
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
@@ -67,6 +68,43 @@ function nullControlPlane() {
67
68
  },
68
69
  });
69
70
  }
71
+ /**
72
+ * #1819: the ONE object in a deployment's scope namespace that carries the rewind hold (see
73
+ * "the rewind hold" on `CloudflareScopeHost`). A name no `ScopeId` can parse to, so it is
74
+ * never a scope.
75
+ */
76
+ export const SWITCH_HOLDS_NAME = 'substrat:switch-holds';
77
+ /**
78
+ * #1819: how long one read of the hold serves a sweep pass. The pass reads the whole hold once
79
+ * (it is normally empty) rather than once per scope, so a scope never rewound costs no RPC.
80
+ */
81
+ export const SWITCH_HOLD_SNAPSHOT_MS = 2_000;
82
+ /**
83
+ * #1819: how long a rewind waits between writing its hold and rewinding. This is what makes the
84
+ * snapshot safe. The pass reads its snapshot only AFTER the scope's own state read, and re-reads
85
+ * one older than `SWITCH_HOLD_SNAPSHOT_MS`. A state read that met rewound storage therefore
86
+ * finished at least this long after the hold was written, so a snapshot taken before the hold
87
+ * is past its age by then and is re-read. Each side measures a duration on its own clock;
88
+ * nothing compares two clocks.
89
+ */
90
+ export const SWITCH_HOLD_SETTLE_MS = SWITCH_HOLD_SNAPSHOT_MS + 1_000;
91
+ /**
92
+ * #1839: how many times a rewind waits again, after its settle, for a row a late OFF added to its
93
+ * claim to be `SWITCH_HOLD_SETTLE_MS` old. The settle argument above holds per ROW, so a row
94
+ * added during the settle needs its own full settle before the rewind arms. Each late OFF can
95
+ * cost up to one more settle; past this many, the rewind is REFUSED rather than armed with a young
96
+ * row, so a switch flapping through the wait cannot hold a rewind forever. See `settleClaim`.
97
+ */
98
+ export const SWITCH_HOLD_EXTRA_WAITS = 3;
99
+ /**
100
+ * #1819: how long a claim may stay `pending` before a switch move treats it as armed with no
101
+ * doomed instance. A claim is pending from capture until its rewind armed or refused, which is
102
+ * `SWITCH_HOLD_SETTLE_MS` times at most `1 + SWITCH_HOLD_EXTRA_WAITS`, plus a few calls. One still
103
+ * pending long after that belongs to a rewinding request that died in between, and without this
104
+ * bound its hold could never be released. The age is `held_at`, stamped on the hold object's
105
+ * clock, against the releasing host's clock: minutes, not seconds.
106
+ */
107
+ export const SWITCH_HOLD_PENDING_MAX_MS = 5 * 60_000;
70
108
  export class CloudflareScopeHost {
71
109
  admin;
72
110
  scopeNs;
@@ -135,6 +173,17 @@ export class CloudflareScopeHost {
135
173
  peerSwitchDelegation;
136
174
  /** #1705 PR 3: the replay lever's reach into the deployment serving a consumer scope. */
137
175
  importCursorDelegation;
176
+ /**
177
+ * #1819: the last read of the rewind hold, and when it was taken (before the read went out).
178
+ * Per instance, so per pass: the host is built per request, and never outlives one.
179
+ */
180
+ holdSnapshot = null;
181
+ /**
182
+ * #1819: the hold read in flight, and when it went out. The sweepers run up to eight scopes
183
+ * at once on one host, and each can find the snapshot missing or old at the same moment; they
184
+ * join this one read rather than each sending their own.
185
+ */
186
+ holdRefresh = null;
138
187
  /**
139
188
  * MUST be constructed per request. Never cache an instance across requests.
140
189
  *
@@ -568,9 +617,12 @@ export class CloudflareScopeHost {
568
617
  * `/internal/restore`. The control plane is the gate and the auditor; this end
569
618
  * just replaces its own bytes with the dump's, migration frontier included.
570
619
  */
571
- async restoreScopeLocal(scopeId, tables) {
572
- await this.scopeStub(scopeId).importDump(tables, scopeId);
573
- return { tables: tables.length };
620
+ async restoreScopeLocal(scopeId, tables,
621
+ /** #1742: the directory's recorded-off modules, switched off on THIS scope in the replay's
622
+ * own event — a dump from before a switch was pulled brings its grants back live. */
623
+ opts) {
624
+ const switchedOff = await this.scopeStub(scopeId).importDump(tables, scopeId, opts?.switchedOff ? { moduleIds: opts.switchedOff, at: new Date().toISOString() } : undefined);
625
+ return { tables: tables.length, ...(opts?.switchedOff ? { switchedOff } : {}) };
574
626
  }
575
627
  /**
576
628
  * Re-apply the vertical's OWN role definitions to one scope, CP-lessly — the
@@ -592,6 +644,10 @@ export class CloudflareScopeHost {
592
644
  */
593
645
  async deleteScopeLocal(scopeId) {
594
646
  await this.scopeStub(scopeId).destroyStorage();
647
+ // #1819: a deleted scope runs nothing; its claims go with it. Best effort, never a failed reap.
648
+ await this.switchHoldsStub()
649
+ .switchHoldRelease(scopeId, null, null)
650
+ .catch(() => undefined);
595
651
  }
596
652
  /**
597
653
  * Dump one scope's tables from THIS deployment — the data half of a governed
@@ -681,9 +737,12 @@ export class CloudflareScopeHost {
681
737
  * Rewind one scope to a pre-migration bookmark (#286's backout) — schema AND data,
682
738
  * discarding every write since; the DO enforces the freshness window and restarts
683
739
  * itself to complete the restore. Behind the vertical's `/internal/rewind`.
740
+ *
741
+ * #1819: what the scope has switched off is held outside it first, so the rewound grants
742
+ * run nothing until the switch is back in the scope. See `rewindHolding`.
684
743
  */
685
744
  async rewindScopeLocal(scopeId, bookmark, opts) {
686
- return this.scopeStub(scopeId).rewindToBookmark(bookmark, opts);
745
+ return this.rewindHolding(scopeId, () => this.scopeStub(scopeId).rewindToBookmark(bookmark, opts));
687
746
  }
688
747
  registerModule(registration) {
689
748
  const manifest = moduleManifest.parse(registration.manifest);
@@ -779,13 +838,14 @@ export class CloudflareScopeHost {
779
838
  // per-scope schedule kill switch is `revokeFromSystem` (#1666), and its OFF marker is
780
839
  // not a grant, so no reconcile can seat it away; `restoreToSystem` is the only way back.
781
840
  // (`grantToSystem` still clears a tuple's tombstone, but it does not move the switch.)
841
+ const seats = [];
782
842
  for (const [moduleId, schedules] of this.moduleSchedules) {
783
843
  const perms = new Set();
784
844
  for (const s of schedules)
785
845
  for (const p of s.permissions)
786
846
  perms.add(p);
787
847
  for (const perm of perms) {
788
- await this.scopeStub(input.scopeId).seatTuple(`system:${moduleId}`, `granted:${perm}`, `scope:${input.scopeId}`, null);
848
+ seats.push({ subject: `system:${moduleId}`, relation: `granted:${perm}`, object: `scope:${input.scopeId}`, expires_at: null });
789
849
  }
790
850
  }
791
851
  // #1706: every declared PEER holds its keys on the scope from provisioning on — the one
@@ -793,14 +853,22 @@ export class CloudflareScopeHost {
793
853
  // schedule grants above, so a grant a switch tombstoned stays tombstoned and a
794
854
  // switched-off peer gets nothing seated.
795
855
  for (const seat of peerSeats(collectPeers(this.peerSources), input.scopeId)) {
796
- await this.scopeStub(input.scopeId).seatTuple(seat.subject, seat.relation, seat.object, null);
856
+ seats.push({ subject: seat.subject, relation: seat.relation, object: seat.object, expires_at: null });
797
857
  }
798
858
  // #1674: re-assert the recorded OFF positions after the seat (`system-switch-record.ts`).
799
859
  // Only where this seat landed in the scope's real store — a CP-less host, or a scope
800
860
  // bound to no vertical. A scope a vertical's deployment serves is seated there, later,
801
861
  // and re-asserted after that deployment's reconcile instead.
802
- if (this.cpLess || record.vertical === null) {
803
- await this.admin.reassertSystemSwitches(actor, { tenantId: input.tenantId, scopeId: input.scopeId });
862
+ //
863
+ // #1742: in the SAME unit as the seat, so no sweep can land between them; the re-assert
864
+ // after it then finds them off, and audits what the unit moved.
865
+ const ownStore = this.cpLess || record.vertical === null;
866
+ const recordedOff = ownStore ? await this.cp.switchedOffModulesOf(input.tenantId, input.scopeId) : [];
867
+ const switchedOff = await this.scopeStub(input.scopeId).seatTuples(seats, recordedOff.length
868
+ ? { scopeId: input.scopeId, moduleIds: recordedOff, at: new Date().toISOString() }
869
+ : undefined);
870
+ if (ownStore) {
871
+ await this.admin.reassertSystemSwitches(actor, { tenantId: input.tenantId, scopeId: input.scopeId }, { appliedInUnit: switchedOff });
804
872
  }
805
873
  // Audit a real provision only; an idempotent re-provision changed nothing.
806
874
  if (created) {
@@ -1120,8 +1188,17 @@ export class CloudflareScopeHost {
1120
1188
  const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1121
1189
  if (!existing)
1122
1190
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1123
- await this.scopeStub(scopeId).importDump(dump.tables, scopeId);
1191
+ // #1742: a dump from before a switch was pulled brings the module's grants back live, so
1192
+ // the directory's recorded-off modules go back off in the replay's own event. Only where
1193
+ // this is the scope's real store, as for the provision's seat above; a hosted scope is
1194
+ // restored in its deployment, which the platform carries the same list to.
1195
+ const ownStore = this.cpLess || existing.vertical === null;
1196
+ const recordedOff = ownStore ? await this.cp.switchedOffModulesOf(tenantId, scopeId) : [];
1197
+ const switchedOff = await this.scopeStub(scopeId).importDump(dump.tables, scopeId, recordedOff.length ? { moduleIds: recordedOff, at: new Date().toISOString() } : undefined);
1124
1198
  await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1199
+ if (recordedOff.length) {
1200
+ await this.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: switchedOff });
1201
+ }
1125
1202
  }
1126
1203
  async snapshotScope(actor, tenantId, scopeId, opts) {
1127
1204
  const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
@@ -1554,7 +1631,12 @@ export class CloudflareScopeHost {
1554
1631
  const state = await stub.systemScheduleState(moduleId);
1555
1632
  if (state === 'ungranted')
1556
1633
  return report;
1557
- if (state === 'off') {
1634
+ // #1819: a scope rewound to before its switch was pulled answers `on`. The rewind held the
1635
+ // module outside the scope; read AFTER the state, which is the order `switchHeld` needs.
1636
+ const hold = state === 'on' ? await this.switchHeld(scopeId, moduleId) : { held: false };
1637
+ if (hold.error)
1638
+ report.errors.push({ operation: 'switch-hold', error: hold.error });
1639
+ if (state === 'off' || hold.held) {
1558
1640
  for (const schedule of schedules) {
1559
1641
  report.skipped += 1;
1560
1642
  report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
@@ -1572,10 +1654,14 @@ export class CloudflareScopeHost {
1572
1654
  report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
1573
1655
  continue;
1574
1656
  }
1657
+ // #1525: one id for THIS call, minted before the invoke so it is the same id
1658
+ // whether the operation succeeds or throws — an event or a denial the call
1659
+ // produces either way carries it, and so does the row below.
1660
+ const invocationId = ulid();
1575
1661
  let status = 'ok';
1576
1662
  try {
1577
1663
  const scope = await this.getSystemScope(moduleId, tenantId, scopeId);
1578
- await scope.invoke(schedule.operation, schedule.input);
1664
+ await scope.invoke(schedule.operation, schedule.input, { invocationId });
1579
1665
  report.fired += 1;
1580
1666
  }
1581
1667
  catch (err) {
@@ -1588,7 +1674,7 @@ export class CloudflareScopeHost {
1588
1674
  }
1589
1675
  // #1288: 'schedule', whatever this operation happens to be called — including
1590
1676
  // `freshness:<something>`, which is exactly the row the evaluator no longer eats.
1591
- await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule');
1677
+ await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule', invocationId);
1592
1678
  report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
1593
1679
  }
1594
1680
  return report;
@@ -1633,6 +1719,18 @@ export class CloudflareScopeHost {
1633
1719
  return {
1634
1720
  tenantId,
1635
1721
  scopeId,
1722
+ // #1746: the door decided it — the same precedence `asPrincipalId` uses below.
1723
+ subjectKind: principal !== undefined
1724
+ ? 'principal'
1725
+ : connectionId !== undefined
1726
+ ? 'connection'
1727
+ : systemModuleId !== undefined
1728
+ ? 'system'
1729
+ : capabilitySession !== undefined
1730
+ ? 'capability'
1731
+ : verticalCaller !== undefined
1732
+ ? 'vertical'
1733
+ : undefined,
1636
1734
  invoke: async (operation, input, invokeOptions) => {
1637
1735
  // Entitlement gate (§4.3): a module loads for a tenant only if the tenant holds its
1638
1736
  // SKU flag. The COORDINATOR gates the console-managed path against the shared CP
@@ -1729,6 +1827,8 @@ export class CloudflareScopeHost {
1729
1827
  invokeOptions?.onEntityVersion?.(envelope.concurrency.version);
1730
1828
  if (envelope.idempotency?.replayed)
1731
1829
  invokeOptions?.onIdempotentReplay?.();
1830
+ if (envelope.emitted)
1831
+ invokeOptions?.onEmitted?.(envelope.emitted);
1732
1832
  return envelope.result;
1733
1833
  },
1734
1834
  };
@@ -1891,7 +1991,8 @@ export class CloudflareScopeHost {
1891
1991
  };
1892
1992
  const writeGrant = async (subject, permission, node, entity, expiresAt) => {
1893
1993
  if (entity) {
1894
- await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, expiresAt ?? null);
1994
+ await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, entityObjectRef(entity, 'HostAdmin'), // #1856: grant and grantToOrg both land here
1995
+ expiresAt ?? null);
1895
1996
  }
1896
1997
  else if (node.scopeId) {
1897
1998
  await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, `scope:${node.scopeId}`, expiresAt ?? null);
@@ -1997,9 +2098,11 @@ export class CloudflareScopeHost {
1997
2098
  vertical = rec.vertical;
1998
2099
  }
1999
2100
  const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
2101
+ // #1819: a scope whose store is here was rewound here, so `switchInScope` releases its
2102
+ // hold here too. A delegated move releases in the deployment serving the scope.
2000
2103
  const move = (moduleId, to, at) => delegation
2001
2104
  ? delegation.switch({ tenantId, scopeId, moduleId, to })
2002
- : this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
2105
+ : this.switchInScope(scopeId, moduleId, to, at);
2003
2106
  return { vertical, move };
2004
2107
  };
2005
2108
  const switchSystem = async (actor, raw, to) => {
@@ -2198,20 +2301,52 @@ export class CloudflareScopeHost {
2198
2301
  * so it lands wherever an operator's OFF would have — for a hosted scope, over the
2199
2302
  * `/internal/system-switch` route every deployment since #1666 answers.
2200
2303
  */
2201
- const reassertSystemSwitchesOf = async (actor, node) => {
2304
+ const reassertSystemSwitchesOf = async (actor, node, opts) => {
2202
2305
  const { tenantId, scopeId } = node;
2203
2306
  const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
2204
- const modules = await this.cp.switchedOffModulesOf(tenantId, scopeId);
2307
+ const recorded = new Map(await this.cp.systemSwitchRecordsOf(tenantId, scopeId));
2308
+ // #1742 review: moves the deployment made from a stale list (restored ON after the list
2309
+ // was read), to undo before the OFF pass below.
2310
+ const reverts = staleCarryReverts(recorded, opts?.appliedInUnit);
2205
2311
  // A hosted scope with no delegation configured (Copilot review): this host's own
2206
2312
  // namespace is the module-less placeholder, where the switch would answer `held: false`
2207
2313
  // quietly — a re-assert reported done, and a receipt written, while the deployment
2208
2314
  // serving the scope keeps its schedules running. Refused loudly instead, as the status
2209
2315
  // read is, and only when a re-assert is owed: nothing recorded, nothing to refuse.
2210
- if (modules.length > 0 && !this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
2316
+ const owed = reverts.length > 0 || [...recorded.values()].includes('off');
2317
+ if (owed && !this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
2211
2318
  throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot re-assert ` +
2212
2319
  `its switched-off schedules in the deployment serving it`);
2213
2320
  }
2214
2321
  const at = new Date().toISOString();
2322
+ const reverted = new Set();
2323
+ for (const moduleId of reverts) {
2324
+ // Re-read immediately before the move: a staff OFF that completed since the read above
2325
+ // (record `off`) must not get a transient ON a due schedule could run in.
2326
+ const current = new Map(await this.cp.systemSwitchRecordsOf(tenantId, scopeId));
2327
+ if (current.get(moduleId) !== 'on')
2328
+ continue;
2329
+ const outcome = await move(moduleId, 'on', at);
2330
+ reverted.add(moduleId);
2331
+ if (outcome.changed) {
2332
+ await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
2333
+ operationId: ulid(),
2334
+ ...staleCarryRevertRow(moduleId, outcome),
2335
+ });
2336
+ }
2337
+ }
2338
+ // Read AFTER the reverts: an OFF that landed meanwhile is switched off below.
2339
+ const modules = await this.cp.switchedOffModulesOf(tenantId, scopeId);
2340
+ // #1742: what the deployment already switched off inside its own unit, audited here —
2341
+ // the move below answers `changed: false` for it and would write no row.
2342
+ // A move the revert above undid is not credited as an in-unit OFF.
2343
+ const applied = opts?.appliedInUnit?.filter((a) => !reverted.has(a.moduleId));
2344
+ for (const row of inUnitMovesToAudit(modules, applied)) {
2345
+ await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
2346
+ operationId: ulid(),
2347
+ ...row,
2348
+ });
2349
+ }
2215
2350
  const results = [];
2216
2351
  for (const moduleId of modules) {
2217
2352
  const outcome = await move(moduleId, 'off', at);
@@ -2414,7 +2549,12 @@ export class CloudflareScopeHost {
2414
2549
  }
2415
2550
  }
2416
2551
  else {
2417
- await writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
2552
+ // #1743: refused while the record holds the module off on any scope — checked and
2553
+ // written in one control-plane call, where the record and the tenant tuple both live.
2554
+ const off = await this.cp.writeTenantSystemGrant(grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null);
2555
+ if (off.length > 0) {
2556
+ throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
2557
+ }
2418
2558
  }
2419
2559
  await this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
2420
2560
  if (!grant.node.scopeId)
@@ -3050,7 +3190,8 @@ export class CloudflareScopeHost {
3050
3190
  // host's namespace here would PITR an unrelated, unused DO.
3051
3191
  return { rewindingTo: bookmark };
3052
3192
  }
3053
- return this.scopeStub(scopeId).rewindToBookmark(bookmark, { force: opts?.force });
3193
+ // #1819: the same rewind a dispatched vertical runs, holding what the scope switched off.
3194
+ return this.rewindScopeLocal(scopeId, bookmark, { force: opts?.force });
3054
3195
  },
3055
3196
  createOrg: async (actor, input) => {
3056
3197
  const parsed = createOrgInput.parse(input);
@@ -3338,7 +3479,7 @@ export class CloudflareScopeHost {
3338
3479
  facetEvents: async (actor, tenantId, scopeId, input) => {
3339
3480
  await this.scopeRecordForRead(tenantId, scopeId);
3340
3481
  const result = await this.scopeStub(scopeId).facetEvents(input);
3341
- await this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
3482
+ await this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, delegatedReadParams.facetEvents(input, result), result.buckets.length);
3342
3483
  return result;
3343
3484
  },
3344
3485
  entityHistory: async (actor, tenantId, scopeId, input) => {
@@ -4052,6 +4193,7 @@ export class CloudflareScopeHost {
4052
4193
  request_id: entry.requestId ?? null,
4053
4194
  event_type: entry.eventType ?? null,
4054
4195
  observed_at: entry.observedAt ?? null,
4196
+ platform_requests: entry.platformRequests == null ? null : JSON.stringify(entry.platformRequests),
4055
4197
  at: entry.at ?? new Date().toISOString(),
4056
4198
  });
4057
4199
  },
@@ -4331,6 +4473,274 @@ export class CloudflareScopeHost {
4331
4473
  // via newUniqueId (K-7) and stores the mapping in the directory — deferred.
4332
4474
  return this.scopeNs.get(this.scopeNs.idFromName(scopeId));
4333
4475
  }
4476
+ // -- the rewind hold (#1819) ------------------------------------------------
4477
+ // A PITR rewind to a bookmark from before a schedule kill switch was pulled brings the
4478
+ // module's `system:` grants back live, and nothing in the rewound storage says otherwise.
4479
+ // The scope's OFF modules are read while the pre-rewind storage still holds them, and each
4480
+ // rewind CLAIMS them on `SWITCH_HOLDS_NAME`, which the rewind cannot reach. A module is held
4481
+ // while any claim on it exists, and `runDueSchedules` skips a held module.
4482
+ //
4483
+ // A claim is released only by a switch move KNOWN to survive its rewind (`switchInScope`): a
4484
+ // move on the restored storage, after the rewind armed. A move during the settle, or one the
4485
+ // doomed instance applied, lands in storage the restart discards, so it releases nothing. A
4486
+ // definite refusal drops only that rewind's own claim, so a concurrent rewind keeps its own.
4487
+ // An OFF landing in that same discarded storage joins the claims that would discard it (#1839).
4488
+ switchHoldsStub() {
4489
+ return this.scopeNs.get(this.scopeNs.idFromName(SWITCH_HOLDS_NAME));
4490
+ }
4491
+ /**
4492
+ * Is this module held off on this scope? Read from one snapshot of the whole hold, re-read
4493
+ * once it is older than `SWITCH_HOLD_SNAPSHOT_MS`. Called only AFTER the scope's own state
4494
+ * read, which is the order `SWITCH_HOLD_SETTLE_MS` relies on.
4495
+ *
4496
+ * A failed read fails OPEN except for what this pass already knows is held. Failing closed
4497
+ * would turn one object's outage into every schedule of the deployment stopping; failing open
4498
+ * reopens only the gap this closes, for a rewind that lands during the outage, and the next
4499
+ * reconcile still switches that module off. The error is answered, for the pass to report.
4500
+ */
4501
+ async switchHeld(scopeId, moduleId) {
4502
+ const at = Date.now();
4503
+ let error;
4504
+ if (!this.holdSnapshot || at - this.holdSnapshot.at > SWITCH_HOLD_SNAPSHOT_MS) {
4505
+ const inflight = this.holdRefresh;
4506
+ // Join a read that went out within the snapshot age of this consult: its result is then as
4507
+ // fresh as one this consult would take itself, which is what the settle argument needs.
4508
+ // Only the consult that sent the read reports its failure.
4509
+ if (inflight && at - inflight.at <= SWITCH_HOLD_SNAPSHOT_MS) {
4510
+ await inflight.done;
4511
+ }
4512
+ else {
4513
+ const refresh = { at, done: this.refreshHolds(at) };
4514
+ this.holdRefresh = refresh;
4515
+ try {
4516
+ ({ error } = await refresh.done);
4517
+ }
4518
+ finally {
4519
+ if (this.holdRefresh === refresh)
4520
+ this.holdRefresh = null;
4521
+ }
4522
+ }
4523
+ }
4524
+ const held = this.holdSnapshot?.held.has(`${scopeId} ${moduleId}`) ?? false;
4525
+ return { held, ...(error ? { error } : {}) };
4526
+ }
4527
+ /** One read of the whole hold into the snapshot, stamped with when it went out. Never throws. */
4528
+ async refreshHolds(at) {
4529
+ try {
4530
+ const rows = await this.switchHoldsStub().switchHoldsAll();
4531
+ // A slow read that lands after a newer one never replaces it.
4532
+ if (!this.holdSnapshot || at >= this.holdSnapshot.at) {
4533
+ this.holdSnapshot = { at, held: new Set(rows.map((r) => `${r.scopeId} ${r.moduleId}`)) };
4534
+ }
4535
+ return {};
4536
+ }
4537
+ catch (err) {
4538
+ const known = this.holdSnapshot?.held;
4539
+ const why = err instanceof Error ? err.message : String(err);
4540
+ // Deliberately re-stamped, so a failing hold object is retried once per snapshot age rather
4541
+ // than hammered per scope. For that long, other scopes reuse this possibly pre-hold set
4542
+ // silently, and only the consult that sent this read reports the error: the declared
4543
+ // fail-open, attributed once.
4544
+ if (!this.holdSnapshot || at >= this.holdSnapshot.at)
4545
+ this.holdSnapshot = { at, held: known ?? new Set() };
4546
+ return {
4547
+ error: known
4548
+ ? `switch hold unreadable (${why}); the ${known.size} hold(s) from this pass's last good read ` +
4549
+ `still applied, any newer ones did not`
4550
+ : `switch hold unreadable (${why}); no earlier read in this pass, so no hold applied`,
4551
+ };
4552
+ }
4553
+ }
4554
+ /**
4555
+ * Move one module's switch in this host's own scope DO, and release the claims the move is
4556
+ * known to survive. The claims are read BEFORE the move (S0). A claim in S0 that was already
4557
+ * `armed` had its rewind armed before the move started, and a Durable Object has one live
4558
+ * instance at a time. So the instance that applied the move is either the doomed one (its
4559
+ * write is discarded: keep the claim) or one started after it, on the restored storage (the
4560
+ * write persists: release). A claim still `pending` in S0 may belong to a rewind that has not
4561
+ * armed yet, which would discard this move too: keep it, unless it is older than
4562
+ * `SWITCH_HOLD_PENDING_MAX_MS`. A claim created after S0 is never touched.
4563
+ *
4564
+ * An ON releases every claim in S0, surviving or not. A claim exists to keep off a module the
4565
+ * operator had off, and ON is their newer word: the directory records it before the move. If
4566
+ * the ON itself is discarded by a rewind, the scope comes back as the bookmark had it, and a
4567
+ * claim would then keep the module off while the directory and the status read both say ON.
4568
+ * The same call tombstones the module on the hold object (`switchHoldOn`), after the move: a
4569
+ * rewind that read the switch before this ON cannot claim the module from that read afterwards
4570
+ * (#1839). The release still covers only S0: a row a stale read inserted between S0 and the
4571
+ * tombstone, into a claim that did not hold the module at S0, stays. That window is a few calls,
4572
+ * and it errs toward OFF, until the next surviving move of the switch.
4573
+ *
4574
+ * An OFF that changed the switch JOINS the claims it would not survive (#1839): each claim in S0
4575
+ * or S1 (below) that is still pending (inside the bound), or armed with this move's instance as
4576
+ * its doomed one. Those are exactly the rewinds whose restart discards this OFF, and each of them captured
4577
+ * the module ON. The row takes the claim's state (`switchHoldJoin`, which never recreates a claim
4578
+ * with no rows left) and is stamped on the hold object's clock, and
4579
+ * a pending claim's rewind then waits for it to be a full settle old (`rewindHolding`). The
4580
+ * candidates are read twice, in S0 and again after the move (S1), so a claim written while
4581
+ * this move was queued is joined too. Both reads are scope-wide; S1 is taken only by an OFF
4582
+ * that changed the switch.
4583
+ *
4584
+ * A failed claims read does not stop the move. It is thrown after it, and the caller treats
4585
+ * that as a failed move, with the claims kept, which errs toward OFF. A failed join is thrown
4586
+ * the same way.
4587
+ *
4588
+ * An OFF then an ON of one module, milliseconds apart, can interleave so the ON's release reads
4589
+ * its claims before the OFF's join lands. The joined row then holds the module the operator just
4590
+ * turned ON, until the next surviving move of that switch releases it (another ON does).
4591
+ */
4592
+ async switchInScope(scopeId, moduleId, to, at) {
4593
+ let scopeClaims;
4594
+ let readError;
4595
+ try {
4596
+ scopeClaims = await this.switchHoldsStub().switchHoldClaims(scopeId);
4597
+ }
4598
+ catch (err) {
4599
+ readError = err;
4600
+ }
4601
+ const { instance, ...outcome } = await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
4602
+ if (!scopeClaims)
4603
+ throw readError;
4604
+ const now = Date.now();
4605
+ const pendingLive = (c) => c.state === 'pending' && now - Date.parse(c.heldAt) <= SWITCH_HOLD_PENDING_MAX_MS;
4606
+ const before = scopeClaims.filter((c) => c.moduleId === moduleId);
4607
+ if (to === 'on') {
4608
+ // Every ON, claims or not: its tombstone is what keeps a rewind's stale read from claiming
4609
+ // the module after this (#1839).
4610
+ this.holdSnapshot = null;
4611
+ await this.switchHoldsStub().switchHoldOn(scopeId, moduleId, before.map((c) => c.claimId));
4612
+ }
4613
+ else if (outcome.held && before.length > 0) {
4614
+ const survived = before.filter((c) => !pendingLive(c) && c.doomed !== instance);
4615
+ if (survived.length > 0) {
4616
+ this.holdSnapshot = null;
4617
+ await this.switchHoldsStub().switchHoldRelease(scopeId, moduleId, survived.map((c) => c.claimId));
4618
+ }
4619
+ }
4620
+ if (to === 'off' && outcome.changed) {
4621
+ // S1, read after the move: a claim written while this move was queued behind the capture
4622
+ // is in S1 only. S0 still counts: a claim pending in S0 may have armed with another
4623
+ // instance by S1, and this move may have landed before that arm.
4624
+ const after = await this.switchHoldsStub().switchHoldClaims(scopeId);
4625
+ const joins = new Set([...scopeClaims, ...after].filter((c) => pendingLive(c) || c.doomed === instance).map((c) => c.claimId));
4626
+ if (joins.size > 0) {
4627
+ this.holdSnapshot = null;
4628
+ await this.switchHoldsStub().switchHoldJoin(scopeId, moduleId, [...joins]);
4629
+ }
4630
+ }
4631
+ return outcome;
4632
+ }
4633
+ /**
4634
+ * Rewind one scope, claiming what it has switched off, and what an earlier rewind still holds on
4635
+ * it. The OFF modules are read and claimed BEFORE the rewind, while this storage still holds
4636
+ * them, and the rewind then waits until every row of its claim is `SWITCH_HOLD_SETTLE_MS` old
4637
+ * (`settleClaim`). Its claim is `armed` once the rewind arms, naming the doomed
4638
+ * instance. Only a DEFINITE refusal drops the claim, and only this rewind's own. Any other throw
4639
+ * may come after the DO armed the bookmark, so the claim is armed on what a probe finds: the
4640
+ * serving instance if it armed, else none. A probe that fails leaves the claim pending, and
4641
+ * `SWITCH_HOLD_PENDING_MAX_MS` bounds that.
4642
+ *
4643
+ * With nothing to claim, the rewind neither claims nor settles. An OFF that lands between that
4644
+ * status read and the arm (a few calls) is then NOT held at all: the rewound scope runs that
4645
+ * module until the next reconcile re-asserts the switch, #1819's gap for those milliseconds.
4646
+ * Closing it would make every rewind claim and settle, 3 s on each, to cover a few calls.
4647
+ */
4648
+ async rewindHolding(scopeId, rewind) {
4649
+ const holds = this.switchHoldsStub();
4650
+ // What this rewind must keep off: what the storage has off, AND what an earlier rewind still
4651
+ // holds here. A module held by an earlier rewind is ON in this storage (that is why it is
4652
+ // held), so the storage alone would give this rewind no claim on it. Then a re-assert that
4653
+ // lands on the instance THIS rewind dooms would release the earlier claim, and this
4654
+ // rewind's restart would discard the re-assert's write.
4655
+ // The token first: an ON that moves after these reads tombstones its module past it (#1839).
4656
+ const token = await holds.switchHoldToken();
4657
+ const [offInStorage, held] = await Promise.all([this.offInStorage(scopeId), holds.switchHoldsAll()]);
4658
+ const off = [...new Set([...offInStorage, ...held.filter((h) => h.scopeId === scopeId).map((h) => h.moduleId)])];
4659
+ if (off.length === 0)
4660
+ return { rewindingTo: (await rewind()).rewindingTo };
4661
+ const claimId = ulid();
4662
+ await holds.switchHoldClaim(scopeId, off, claimId, token);
4663
+ this.holdSnapshot = null;
4664
+ try {
4665
+ await this.settleClaim(holds, scopeId, claimId);
4666
+ }
4667
+ catch (err) {
4668
+ // Nothing was asked to arm: this rewind never happens, and what it claimed stays in storage.
4669
+ await holds.switchHoldDrop(scopeId, claimId).catch(() => undefined);
4670
+ throw err;
4671
+ }
4672
+ let result;
4673
+ try {
4674
+ result = await rewind();
4675
+ }
4676
+ catch (err) {
4677
+ if (isRewindRefusal(err)) {
4678
+ await holds.switchHoldDrop(scopeId, claimId).catch(() => undefined);
4679
+ }
4680
+ else {
4681
+ const probe = await this.scopeStub(scopeId)
4682
+ .rewindProbe()
4683
+ .catch(() => undefined);
4684
+ if (probe)
4685
+ await holds.switchHoldArm(scopeId, claimId, probe.armed ? probe.instance : null).catch(() => undefined);
4686
+ }
4687
+ throw err;
4688
+ }
4689
+ await holds.switchHoldArm(scopeId, claimId, result.instance ?? null).catch(() => undefined);
4690
+ return { rewindingTo: result.rewindingTo };
4691
+ }
4692
+ /** The modules this scope's own storage has switched off, as a rewind reads them before it arms. */
4693
+ async offInStorage(scopeId) {
4694
+ return (await this.systemGrantsStatusLocal(scopeId))
4695
+ .filter((e) => e.schedules === 'off')
4696
+ .map((e) => e.moduleId);
4697
+ }
4698
+ /**
4699
+ * #1839: wait until every row of this rewind's claim is `SWITCH_HOLD_SETTLE_MS` old. The settle
4700
+ * argument holds per row: a row younger than that when the rewound storage exists can be missed
4701
+ * by a pass whose hold snapshot was read just before it. So after the settle, each round reads
4702
+ * the scope's OFF modules again and claims them (an OFF that landed after the capture, in storage
4703
+ * this rewind discards), then asks the hold object how old the claim's youngest row is, joined
4704
+ * rows (`switchInScope`) included. The age is measured on the hold object's clock, the same one
4705
+ * that stamped the row; this host only sleeps the difference. Each status read is preceded by a
4706
+ * token read, so a module an operator turned ON after that read is not claimed from it
4707
+ * (`switchHoldClaim`).
4708
+ *
4709
+ * Bounded at `SWITCH_HOLD_EXTRA_WAITS` more waits: past that, the rewind is REFUSED, with the
4710
+ * refusal prefix. Nothing was asked to arm, so `rewindHolding` drops only this claim, and every
4711
+ * OFF pulled meanwhile stays in the scope's own storage. The owner retries, and the retry's
4712
+ * capture reads them all. Arming instead would leave a row younger than the settle, whose module
4713
+ * a pass with a snapshot read shortly before that row could run once on the rewound storage.
4714
+ *
4715
+ * What remains: an OFF whose join lands after the last age read and before the arm (a few calls),
4716
+ * or on the doomed instance in the moment between the arm and its restart. It is claimed, but by
4717
+ * a row younger than the settle: that module can run once, in a pass whose hold snapshot was read
4718
+ * in the `SWITCH_HOLD_SNAPSHOT_MS` before the row, until that snapshot expires. If the claim has
4719
+ * no rows by then, the OFF has nothing to join (`switchHoldJoin`), and in that same moment it is
4720
+ * not held at all. That happens when an ON emptied the claim, and also when a tombstone refused
4721
+ * every module the capture read, so the capture wrote no rows. An OFF before the last re-read is
4722
+ * claimed by the re-read.
4723
+ */
4724
+ async settleClaim(holds, scopeId, claimId) {
4725
+ await new Promise((resolve) => setTimeout(resolve, SWITCH_HOLD_SETTLE_MS));
4726
+ for (let waits = 0;; waits++) {
4727
+ const token = await holds.switchHoldToken();
4728
+ const off = await this.offInStorage(scopeId);
4729
+ if (off.length > 0) {
4730
+ await holds.switchHoldClaim(scopeId, off, claimId, token);
4731
+ this.holdSnapshot = null;
4732
+ }
4733
+ const youngest = await holds.switchHoldYoungestMs(scopeId, claimId);
4734
+ if (youngest === null || youngest >= SWITCH_HOLD_SETTLE_MS)
4735
+ return;
4736
+ if (waits === SWITCH_HOLD_EXTRA_WAITS) {
4737
+ throw new Error(`${REWIND_REFUSED}schedule switches kept being pulled off while this rewind waited for them to ` +
4738
+ `settle (${waits} extra waits); nothing was rewound, and those switches stay off. Retry the rewind`);
4739
+ }
4740
+ // Never longer than one settle, even if the hold object's clock moved back under a restart.
4741
+ await new Promise((resolve) => setTimeout(resolve, SWITCH_HOLD_SETTLE_MS - Math.max(0, youngest)));
4742
+ }
4743
+ }
4334
4744
  // -- live reads (#938): the door ------------------------------------------
4335
4745
  // The coordinator's half of the live-read path. It decides two things and no more:
4336
4746
  // whether this CONNECTION can carry a push at all, and who is asking. What a
@@ -4482,9 +4892,15 @@ export class CloudflareScopeHost {
4482
4892
  async projectScope(tenantId, scopeId) {
4483
4893
  if (!this.scopeLocalPermissions)
4484
4894
  return;
4485
- const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
4895
+ // The directory decides which tenant this scope belongs to (#1738): the scope's first
4896
+ // projection pins its `provisioned_for` receipt, so a tenant taken from a request rather
4897
+ // than from the record must not reach `applyProjection` — it would pin the wrong one for
4898
+ // good. Read first, so a pair the directory does not hold writes nothing.
4486
4899
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
4487
- const connectionKeys = await this.connectionKeyRows(tenantId, scope?.vertical);
4900
+ if (!scope)
4901
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4902
+ const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
4903
+ const connectionKeys = await this.connectionKeyRows(tenantId, scope.vertical);
4488
4904
  await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, connectionKeys);
4489
4905
  }
4490
4906
  /**
@@ -4526,13 +4942,23 @@ export class CloudflareScopeHost {
4526
4942
  // rather than once for the tenant — memoized, because a fan-out over twenty scopes
4527
4943
  // of one vertical must not mint or re-read twenty times.
4528
4944
  const keysByVertical = new Map();
4529
- await Promise.all(scopes.map(async (s) => {
4945
+ // allSettled, not all (#1738): a scope that refuses (its `provisioned_for` receipt names
4946
+ // another tenant) must not stop its siblings converging, or one bad scope would freeze every
4947
+ // revoke for the tenant's healthy ones. The failure is still loud, after the rest have landed.
4948
+ const settled = await Promise.allSettled(scopes.map(async (s) => {
4530
4949
  const vertical = s.vertical ?? '';
4531
4950
  if (!keysByVertical.has(vertical)) {
4532
4951
  keysByVertical.set(vertical, this.connectionKeyRows(tenantId, s.vertical));
4533
4952
  }
4534
4953
  await this.scopeStub(s.scope_id).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, await keysByVertical.get(vertical));
4535
4954
  }));
4955
+ const failed = settled.flatMap((r, i) => (r.status === 'rejected' ? [{ scope: scopes[i].scope_id, reason: r.reason }] : []));
4956
+ if (failed.length === 0)
4957
+ return;
4958
+ // One aggregated error, so the caller learns which scopes did not converge and that the
4959
+ // rest did.
4960
+ const named = failed.map((f) => `${f.scope}: ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`).join('; ');
4961
+ throw substratError('conflict', `projection reached ${scopes.length - failed.length} of ${scopes.length} scopes of tenant ${tenantId}; not converged — ${named}`);
4536
4962
  }
4537
4963
  /**
4538
4964
  * Re-project a tenant's full state into every one of its scopes — the
@@ -4556,7 +4982,7 @@ export class CloudflareScopeHost {
4556
4982
  async provisionScopeLocal(input) {
4557
4983
  const stub = this.scopeStub(input.scopeId);
4558
4984
  await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
4559
- await stub.applyProjection(input.tenantId, input.roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source })), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
4985
+ const switchedOff = await stub.applyProjection(input.tenantId, input.roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source })), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
4560
4986
  // Only PROJECT (and thereby switch on strict enforcement) when the platform actually
4561
4987
  // supplies entitlements. Omitting them leaves the scope un-projected and trusting-
4562
4988
  // upstream, so a vertical whose provision path predates #304 is never denied — it
@@ -4645,7 +5071,13 @@ export class CloudflareScopeHost {
4645
5071
  key_id: k.keyId,
4646
5072
  public_key: k.publicKey,
4647
5073
  }))
5074
+ : undefined,
5075
+ // #1742: the recorded-off modules, switched off after the seat in the same unit, on the
5076
+ // scope this call provisions — the one the seat's tuples name.
5077
+ input.switchedOff
5078
+ ? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString() }
4648
5079
  : undefined);
5080
+ return input.switchedOff ? { switchedOff } : {};
4649
5081
  }
4650
5082
  /**
4651
5083
  * Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
@@ -4718,7 +5150,8 @@ export class CloudflareScopeHost {
4718
5150
  * (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
4719
5151
  */
4720
5152
  async grantEntityLocal(scopeId, principal, permission, entity) {
4721
- await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, `${entity.entityType}:${entity.entityId}`, null);
5153
+ await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
5154
+ null);
4722
5155
  }
4723
5156
  // -- the connector write-back's far end (#574) -----------------------------
4724
5157
  // A CP-less dispatch vertical cannot run a connector, so the shared control
@@ -4776,7 +5209,8 @@ export class CloudflareScopeHost {
4776
5209
  async systemSwitchLocal(scopeId, moduleId, to) {
4777
5210
  // Parsed on the way out: this is the wire answer the platform reads, and the DO's
4778
5211
  // plain strings become the published shape here rather than on trust.
4779
- return systemSwitchOutcome.parse(await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, new Date().toISOString()));
5212
+ // #1819: `switchInScope` releases a rewind's hold on the module once the switch lands.
5213
+ return systemSwitchOutcome.parse(await this.switchInScope(scopeId, moduleId, to, new Date().toISOString()));
4780
5214
  }
4781
5215
  /**
4782
5216
  * The far end of the status read for a scope served HERE (#1674): `/internal/system-grants`