@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/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, } 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
  };
@@ -1997,9 +2097,11 @@ export class CloudflareScopeHost {
1997
2097
  vertical = rec.vertical;
1998
2098
  }
1999
2099
  const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
2100
+ // #1819: a scope whose store is here was rewound here, so `switchInScope` releases its
2101
+ // hold here too. A delegated move releases in the deployment serving the scope.
2000
2102
  const move = (moduleId, to, at) => delegation
2001
2103
  ? delegation.switch({ tenantId, scopeId, moduleId, to })
2002
- : this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
2104
+ : this.switchInScope(scopeId, moduleId, to, at);
2003
2105
  return { vertical, move };
2004
2106
  };
2005
2107
  const switchSystem = async (actor, raw, to) => {
@@ -2198,20 +2300,52 @@ export class CloudflareScopeHost {
2198
2300
  * so it lands wherever an operator's OFF would have — for a hosted scope, over the
2199
2301
  * `/internal/system-switch` route every deployment since #1666 answers.
2200
2302
  */
2201
- const reassertSystemSwitchesOf = async (actor, node) => {
2303
+ const reassertSystemSwitchesOf = async (actor, node, opts) => {
2202
2304
  const { tenantId, scopeId } = node;
2203
2305
  const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
2204
- const modules = await this.cp.switchedOffModulesOf(tenantId, scopeId);
2306
+ const recorded = new Map(await this.cp.systemSwitchRecordsOf(tenantId, scopeId));
2307
+ // #1742 review: moves the deployment made from a stale list (restored ON after the list
2308
+ // was read), to undo before the OFF pass below.
2309
+ const reverts = staleCarryReverts(recorded, opts?.appliedInUnit);
2205
2310
  // A hosted scope with no delegation configured (Copilot review): this host's own
2206
2311
  // namespace is the module-less placeholder, where the switch would answer `held: false`
2207
2312
  // quietly — a re-assert reported done, and a receipt written, while the deployment
2208
2313
  // serving the scope keeps its schedules running. Refused loudly instead, as the status
2209
2314
  // 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) {
2315
+ const owed = reverts.length > 0 || [...recorded.values()].includes('off');
2316
+ if (owed && !this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
2211
2317
  throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot re-assert ` +
2212
2318
  `its switched-off schedules in the deployment serving it`);
2213
2319
  }
2214
2320
  const at = new Date().toISOString();
2321
+ const reverted = new Set();
2322
+ for (const moduleId of reverts) {
2323
+ // Re-read immediately before the move: a staff OFF that completed since the read above
2324
+ // (record `off`) must not get a transient ON a due schedule could run in.
2325
+ const current = new Map(await this.cp.systemSwitchRecordsOf(tenantId, scopeId));
2326
+ if (current.get(moduleId) !== 'on')
2327
+ continue;
2328
+ const outcome = await move(moduleId, 'on', at);
2329
+ reverted.add(moduleId);
2330
+ if (outcome.changed) {
2331
+ await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
2332
+ operationId: ulid(),
2333
+ ...staleCarryRevertRow(moduleId, outcome),
2334
+ });
2335
+ }
2336
+ }
2337
+ // Read AFTER the reverts: an OFF that landed meanwhile is switched off below.
2338
+ const modules = await this.cp.switchedOffModulesOf(tenantId, scopeId);
2339
+ // #1742: what the deployment already switched off inside its own unit, audited here —
2340
+ // the move below answers `changed: false` for it and would write no row.
2341
+ // A move the revert above undid is not credited as an in-unit OFF.
2342
+ const applied = opts?.appliedInUnit?.filter((a) => !reverted.has(a.moduleId));
2343
+ for (const row of inUnitMovesToAudit(modules, applied)) {
2344
+ await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
2345
+ operationId: ulid(),
2346
+ ...row,
2347
+ });
2348
+ }
2215
2349
  const results = [];
2216
2350
  for (const moduleId of modules) {
2217
2351
  const outcome = await move(moduleId, 'off', at);
@@ -2414,7 +2548,12 @@ export class CloudflareScopeHost {
2414
2548
  }
2415
2549
  }
2416
2550
  else {
2417
- await writeGrant(subjectRef({ kind: 'system', id: grant.moduleId }), grant.permission, grant.node, undefined, grant.expiresAt);
2551
+ // #1743: refused while the record holds the module off on any scope — checked and
2552
+ // written in one control-plane call, where the record and the tenant tuple both live.
2553
+ const off = await this.cp.writeTenantSystemGrant(grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null);
2554
+ if (off.length > 0) {
2555
+ throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
2556
+ }
2418
2557
  }
2419
2558
  await this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
2420
2559
  if (!grant.node.scopeId)
@@ -3050,7 +3189,8 @@ export class CloudflareScopeHost {
3050
3189
  // host's namespace here would PITR an unrelated, unused DO.
3051
3190
  return { rewindingTo: bookmark };
3052
3191
  }
3053
- return this.scopeStub(scopeId).rewindToBookmark(bookmark, { force: opts?.force });
3192
+ // #1819: the same rewind a dispatched vertical runs, holding what the scope switched off.
3193
+ return this.rewindScopeLocal(scopeId, bookmark, { force: opts?.force });
3054
3194
  },
3055
3195
  createOrg: async (actor, input) => {
3056
3196
  const parsed = createOrgInput.parse(input);
@@ -3338,7 +3478,7 @@ export class CloudflareScopeHost {
3338
3478
  facetEvents: async (actor, tenantId, scopeId, input) => {
3339
3479
  await this.scopeRecordForRead(tenantId, scopeId);
3340
3480
  const result = await this.scopeStub(scopeId).facetEvents(input);
3341
- await this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, input, result.buckets.length);
3481
+ await this.recordAccess(actor, 'facetEvents', { tenantId, scopeId }, delegatedReadParams.facetEvents(input, result), result.buckets.length);
3342
3482
  return result;
3343
3483
  },
3344
3484
  entityHistory: async (actor, tenantId, scopeId, input) => {
@@ -4052,6 +4192,7 @@ export class CloudflareScopeHost {
4052
4192
  request_id: entry.requestId ?? null,
4053
4193
  event_type: entry.eventType ?? null,
4054
4194
  observed_at: entry.observedAt ?? null,
4195
+ platform_requests: entry.platformRequests == null ? null : JSON.stringify(entry.platformRequests),
4055
4196
  at: entry.at ?? new Date().toISOString(),
4056
4197
  });
4057
4198
  },
@@ -4331,6 +4472,274 @@ export class CloudflareScopeHost {
4331
4472
  // via newUniqueId (K-7) and stores the mapping in the directory — deferred.
4332
4473
  return this.scopeNs.get(this.scopeNs.idFromName(scopeId));
4333
4474
  }
4475
+ // -- the rewind hold (#1819) ------------------------------------------------
4476
+ // A PITR rewind to a bookmark from before a schedule kill switch was pulled brings the
4477
+ // module's `system:` grants back live, and nothing in the rewound storage says otherwise.
4478
+ // The scope's OFF modules are read while the pre-rewind storage still holds them, and each
4479
+ // rewind CLAIMS them on `SWITCH_HOLDS_NAME`, which the rewind cannot reach. A module is held
4480
+ // while any claim on it exists, and `runDueSchedules` skips a held module.
4481
+ //
4482
+ // A claim is released only by a switch move KNOWN to survive its rewind (`switchInScope`): a
4483
+ // move on the restored storage, after the rewind armed. A move during the settle, or one the
4484
+ // doomed instance applied, lands in storage the restart discards, so it releases nothing. A
4485
+ // definite refusal drops only that rewind's own claim, so a concurrent rewind keeps its own.
4486
+ // An OFF landing in that same discarded storage joins the claims that would discard it (#1839).
4487
+ switchHoldsStub() {
4488
+ return this.scopeNs.get(this.scopeNs.idFromName(SWITCH_HOLDS_NAME));
4489
+ }
4490
+ /**
4491
+ * Is this module held off on this scope? Read from one snapshot of the whole hold, re-read
4492
+ * once it is older than `SWITCH_HOLD_SNAPSHOT_MS`. Called only AFTER the scope's own state
4493
+ * read, which is the order `SWITCH_HOLD_SETTLE_MS` relies on.
4494
+ *
4495
+ * A failed read fails OPEN except for what this pass already knows is held. Failing closed
4496
+ * would turn one object's outage into every schedule of the deployment stopping; failing open
4497
+ * reopens only the gap this closes, for a rewind that lands during the outage, and the next
4498
+ * reconcile still switches that module off. The error is answered, for the pass to report.
4499
+ */
4500
+ async switchHeld(scopeId, moduleId) {
4501
+ const at = Date.now();
4502
+ let error;
4503
+ if (!this.holdSnapshot || at - this.holdSnapshot.at > SWITCH_HOLD_SNAPSHOT_MS) {
4504
+ const inflight = this.holdRefresh;
4505
+ // Join a read that went out within the snapshot age of this consult: its result is then as
4506
+ // fresh as one this consult would take itself, which is what the settle argument needs.
4507
+ // Only the consult that sent the read reports its failure.
4508
+ if (inflight && at - inflight.at <= SWITCH_HOLD_SNAPSHOT_MS) {
4509
+ await inflight.done;
4510
+ }
4511
+ else {
4512
+ const refresh = { at, done: this.refreshHolds(at) };
4513
+ this.holdRefresh = refresh;
4514
+ try {
4515
+ ({ error } = await refresh.done);
4516
+ }
4517
+ finally {
4518
+ if (this.holdRefresh === refresh)
4519
+ this.holdRefresh = null;
4520
+ }
4521
+ }
4522
+ }
4523
+ const held = this.holdSnapshot?.held.has(`${scopeId} ${moduleId}`) ?? false;
4524
+ return { held, ...(error ? { error } : {}) };
4525
+ }
4526
+ /** One read of the whole hold into the snapshot, stamped with when it went out. Never throws. */
4527
+ async refreshHolds(at) {
4528
+ try {
4529
+ const rows = await this.switchHoldsStub().switchHoldsAll();
4530
+ // A slow read that lands after a newer one never replaces it.
4531
+ if (!this.holdSnapshot || at >= this.holdSnapshot.at) {
4532
+ this.holdSnapshot = { at, held: new Set(rows.map((r) => `${r.scopeId} ${r.moduleId}`)) };
4533
+ }
4534
+ return {};
4535
+ }
4536
+ catch (err) {
4537
+ const known = this.holdSnapshot?.held;
4538
+ const why = err instanceof Error ? err.message : String(err);
4539
+ // Deliberately re-stamped, so a failing hold object is retried once per snapshot age rather
4540
+ // than hammered per scope. For that long, other scopes reuse this possibly pre-hold set
4541
+ // silently, and only the consult that sent this read reports the error: the declared
4542
+ // fail-open, attributed once.
4543
+ if (!this.holdSnapshot || at >= this.holdSnapshot.at)
4544
+ this.holdSnapshot = { at, held: known ?? new Set() };
4545
+ return {
4546
+ error: known
4547
+ ? `switch hold unreadable (${why}); the ${known.size} hold(s) from this pass's last good read ` +
4548
+ `still applied, any newer ones did not`
4549
+ : `switch hold unreadable (${why}); no earlier read in this pass, so no hold applied`,
4550
+ };
4551
+ }
4552
+ }
4553
+ /**
4554
+ * Move one module's switch in this host's own scope DO, and release the claims the move is
4555
+ * known to survive. The claims are read BEFORE the move (S0). A claim in S0 that was already
4556
+ * `armed` had its rewind armed before the move started, and a Durable Object has one live
4557
+ * instance at a time. So the instance that applied the move is either the doomed one (its
4558
+ * write is discarded: keep the claim) or one started after it, on the restored storage (the
4559
+ * write persists: release). A claim still `pending` in S0 may belong to a rewind that has not
4560
+ * armed yet, which would discard this move too: keep it, unless it is older than
4561
+ * `SWITCH_HOLD_PENDING_MAX_MS`. A claim created after S0 is never touched.
4562
+ *
4563
+ * An ON releases every claim in S0, surviving or not. A claim exists to keep off a module the
4564
+ * operator had off, and ON is their newer word: the directory records it before the move. If
4565
+ * the ON itself is discarded by a rewind, the scope comes back as the bookmark had it, and a
4566
+ * claim would then keep the module off while the directory and the status read both say ON.
4567
+ * The same call tombstones the module on the hold object (`switchHoldOn`), after the move: a
4568
+ * rewind that read the switch before this ON cannot claim the module from that read afterwards
4569
+ * (#1839). The release still covers only S0: a row a stale read inserted between S0 and the
4570
+ * tombstone, into a claim that did not hold the module at S0, stays. That window is a few calls,
4571
+ * and it errs toward OFF, until the next surviving move of the switch.
4572
+ *
4573
+ * An OFF that changed the switch JOINS the claims it would not survive (#1839): each claim in S0
4574
+ * or S1 (below) that is still pending (inside the bound), or armed with this move's instance as
4575
+ * its doomed one. Those are exactly the rewinds whose restart discards this OFF, and each of them captured
4576
+ * the module ON. The row takes the claim's state (`switchHoldJoin`, which never recreates a claim
4577
+ * with no rows left) and is stamped on the hold object's clock, and
4578
+ * a pending claim's rewind then waits for it to be a full settle old (`rewindHolding`). The
4579
+ * candidates are read twice, in S0 and again after the move (S1), so a claim written while
4580
+ * this move was queued is joined too. Both reads are scope-wide; S1 is taken only by an OFF
4581
+ * that changed the switch.
4582
+ *
4583
+ * A failed claims read does not stop the move. It is thrown after it, and the caller treats
4584
+ * that as a failed move, with the claims kept, which errs toward OFF. A failed join is thrown
4585
+ * the same way.
4586
+ *
4587
+ * An OFF then an ON of one module, milliseconds apart, can interleave so the ON's release reads
4588
+ * its claims before the OFF's join lands. The joined row then holds the module the operator just
4589
+ * turned ON, until the next surviving move of that switch releases it (another ON does).
4590
+ */
4591
+ async switchInScope(scopeId, moduleId, to, at) {
4592
+ let scopeClaims;
4593
+ let readError;
4594
+ try {
4595
+ scopeClaims = await this.switchHoldsStub().switchHoldClaims(scopeId);
4596
+ }
4597
+ catch (err) {
4598
+ readError = err;
4599
+ }
4600
+ const { instance, ...outcome } = await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
4601
+ if (!scopeClaims)
4602
+ throw readError;
4603
+ const now = Date.now();
4604
+ const pendingLive = (c) => c.state === 'pending' && now - Date.parse(c.heldAt) <= SWITCH_HOLD_PENDING_MAX_MS;
4605
+ const before = scopeClaims.filter((c) => c.moduleId === moduleId);
4606
+ if (to === 'on') {
4607
+ // Every ON, claims or not: its tombstone is what keeps a rewind's stale read from claiming
4608
+ // the module after this (#1839).
4609
+ this.holdSnapshot = null;
4610
+ await this.switchHoldsStub().switchHoldOn(scopeId, moduleId, before.map((c) => c.claimId));
4611
+ }
4612
+ else if (outcome.held && before.length > 0) {
4613
+ const survived = before.filter((c) => !pendingLive(c) && c.doomed !== instance);
4614
+ if (survived.length > 0) {
4615
+ this.holdSnapshot = null;
4616
+ await this.switchHoldsStub().switchHoldRelease(scopeId, moduleId, survived.map((c) => c.claimId));
4617
+ }
4618
+ }
4619
+ if (to === 'off' && outcome.changed) {
4620
+ // S1, read after the move: a claim written while this move was queued behind the capture
4621
+ // is in S1 only. S0 still counts: a claim pending in S0 may have armed with another
4622
+ // instance by S1, and this move may have landed before that arm.
4623
+ const after = await this.switchHoldsStub().switchHoldClaims(scopeId);
4624
+ const joins = new Set([...scopeClaims, ...after].filter((c) => pendingLive(c) || c.doomed === instance).map((c) => c.claimId));
4625
+ if (joins.size > 0) {
4626
+ this.holdSnapshot = null;
4627
+ await this.switchHoldsStub().switchHoldJoin(scopeId, moduleId, [...joins]);
4628
+ }
4629
+ }
4630
+ return outcome;
4631
+ }
4632
+ /**
4633
+ * Rewind one scope, claiming what it has switched off, and what an earlier rewind still holds on
4634
+ * it. The OFF modules are read and claimed BEFORE the rewind, while this storage still holds
4635
+ * them, and the rewind then waits until every row of its claim is `SWITCH_HOLD_SETTLE_MS` old
4636
+ * (`settleClaim`). Its claim is `armed` once the rewind arms, naming the doomed
4637
+ * instance. Only a DEFINITE refusal drops the claim, and only this rewind's own. Any other throw
4638
+ * may come after the DO armed the bookmark, so the claim is armed on what a probe finds: the
4639
+ * serving instance if it armed, else none. A probe that fails leaves the claim pending, and
4640
+ * `SWITCH_HOLD_PENDING_MAX_MS` bounds that.
4641
+ *
4642
+ * With nothing to claim, the rewind neither claims nor settles. An OFF that lands between that
4643
+ * status read and the arm (a few calls) is then NOT held at all: the rewound scope runs that
4644
+ * module until the next reconcile re-asserts the switch, #1819's gap for those milliseconds.
4645
+ * Closing it would make every rewind claim and settle, 3 s on each, to cover a few calls.
4646
+ */
4647
+ async rewindHolding(scopeId, rewind) {
4648
+ const holds = this.switchHoldsStub();
4649
+ // What this rewind must keep off: what the storage has off, AND what an earlier rewind still
4650
+ // holds here. A module held by an earlier rewind is ON in this storage (that is why it is
4651
+ // held), so the storage alone would give this rewind no claim on it. Then a re-assert that
4652
+ // lands on the instance THIS rewind dooms would release the earlier claim, and this
4653
+ // rewind's restart would discard the re-assert's write.
4654
+ // The token first: an ON that moves after these reads tombstones its module past it (#1839).
4655
+ const token = await holds.switchHoldToken();
4656
+ const [offInStorage, held] = await Promise.all([this.offInStorage(scopeId), holds.switchHoldsAll()]);
4657
+ const off = [...new Set([...offInStorage, ...held.filter((h) => h.scopeId === scopeId).map((h) => h.moduleId)])];
4658
+ if (off.length === 0)
4659
+ return { rewindingTo: (await rewind()).rewindingTo };
4660
+ const claimId = ulid();
4661
+ await holds.switchHoldClaim(scopeId, off, claimId, token);
4662
+ this.holdSnapshot = null;
4663
+ try {
4664
+ await this.settleClaim(holds, scopeId, claimId);
4665
+ }
4666
+ catch (err) {
4667
+ // Nothing was asked to arm: this rewind never happens, and what it claimed stays in storage.
4668
+ await holds.switchHoldDrop(scopeId, claimId).catch(() => undefined);
4669
+ throw err;
4670
+ }
4671
+ let result;
4672
+ try {
4673
+ result = await rewind();
4674
+ }
4675
+ catch (err) {
4676
+ if (isRewindRefusal(err)) {
4677
+ await holds.switchHoldDrop(scopeId, claimId).catch(() => undefined);
4678
+ }
4679
+ else {
4680
+ const probe = await this.scopeStub(scopeId)
4681
+ .rewindProbe()
4682
+ .catch(() => undefined);
4683
+ if (probe)
4684
+ await holds.switchHoldArm(scopeId, claimId, probe.armed ? probe.instance : null).catch(() => undefined);
4685
+ }
4686
+ throw err;
4687
+ }
4688
+ await holds.switchHoldArm(scopeId, claimId, result.instance ?? null).catch(() => undefined);
4689
+ return { rewindingTo: result.rewindingTo };
4690
+ }
4691
+ /** The modules this scope's own storage has switched off, as a rewind reads them before it arms. */
4692
+ async offInStorage(scopeId) {
4693
+ return (await this.systemGrantsStatusLocal(scopeId))
4694
+ .filter((e) => e.schedules === 'off')
4695
+ .map((e) => e.moduleId);
4696
+ }
4697
+ /**
4698
+ * #1839: wait until every row of this rewind's claim is `SWITCH_HOLD_SETTLE_MS` old. The settle
4699
+ * argument holds per row: a row younger than that when the rewound storage exists can be missed
4700
+ * by a pass whose hold snapshot was read just before it. So after the settle, each round reads
4701
+ * the scope's OFF modules again and claims them (an OFF that landed after the capture, in storage
4702
+ * this rewind discards), then asks the hold object how old the claim's youngest row is, joined
4703
+ * rows (`switchInScope`) included. The age is measured on the hold object's clock, the same one
4704
+ * that stamped the row; this host only sleeps the difference. Each status read is preceded by a
4705
+ * token read, so a module an operator turned ON after that read is not claimed from it
4706
+ * (`switchHoldClaim`).
4707
+ *
4708
+ * Bounded at `SWITCH_HOLD_EXTRA_WAITS` more waits: past that, the rewind is REFUSED, with the
4709
+ * refusal prefix. Nothing was asked to arm, so `rewindHolding` drops only this claim, and every
4710
+ * OFF pulled meanwhile stays in the scope's own storage. The owner retries, and the retry's
4711
+ * capture reads them all. Arming instead would leave a row younger than the settle, whose module
4712
+ * a pass with a snapshot read shortly before that row could run once on the rewound storage.
4713
+ *
4714
+ * What remains: an OFF whose join lands after the last age read and before the arm (a few calls),
4715
+ * or on the doomed instance in the moment between the arm and its restart. It is claimed, but by
4716
+ * a row younger than the settle: that module can run once, in a pass whose hold snapshot was read
4717
+ * in the `SWITCH_HOLD_SNAPSHOT_MS` before the row, until that snapshot expires. If the claim has
4718
+ * no rows by then, the OFF has nothing to join (`switchHoldJoin`), and in that same moment it is
4719
+ * not held at all. That happens when an ON emptied the claim, and also when a tombstone refused
4720
+ * every module the capture read, so the capture wrote no rows. An OFF before the last re-read is
4721
+ * claimed by the re-read.
4722
+ */
4723
+ async settleClaim(holds, scopeId, claimId) {
4724
+ await new Promise((resolve) => setTimeout(resolve, SWITCH_HOLD_SETTLE_MS));
4725
+ for (let waits = 0;; waits++) {
4726
+ const token = await holds.switchHoldToken();
4727
+ const off = await this.offInStorage(scopeId);
4728
+ if (off.length > 0) {
4729
+ await holds.switchHoldClaim(scopeId, off, claimId, token);
4730
+ this.holdSnapshot = null;
4731
+ }
4732
+ const youngest = await holds.switchHoldYoungestMs(scopeId, claimId);
4733
+ if (youngest === null || youngest >= SWITCH_HOLD_SETTLE_MS)
4734
+ return;
4735
+ if (waits === SWITCH_HOLD_EXTRA_WAITS) {
4736
+ throw new Error(`${REWIND_REFUSED}schedule switches kept being pulled off while this rewind waited for them to ` +
4737
+ `settle (${waits} extra waits); nothing was rewound, and those switches stay off. Retry the rewind`);
4738
+ }
4739
+ // Never longer than one settle, even if the hold object's clock moved back under a restart.
4740
+ await new Promise((resolve) => setTimeout(resolve, SWITCH_HOLD_SETTLE_MS - Math.max(0, youngest)));
4741
+ }
4742
+ }
4334
4743
  // -- live reads (#938): the door ------------------------------------------
4335
4744
  // The coordinator's half of the live-read path. It decides two things and no more:
4336
4745
  // whether this CONNECTION can carry a push at all, and who is asking. What a
@@ -4482,9 +4891,15 @@ export class CloudflareScopeHost {
4482
4891
  async projectScope(tenantId, scopeId) {
4483
4892
  if (!this.scopeLocalPermissions)
4484
4893
  return;
4485
- const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
4894
+ // The directory decides which tenant this scope belongs to (#1738): the scope's first
4895
+ // projection pins its `provisioned_for` receipt, so a tenant taken from a request rather
4896
+ // than from the record must not reach `applyProjection` — it would pin the wrong one for
4897
+ // good. Read first, so a pair the directory does not hold writes nothing.
4486
4898
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
4487
- const connectionKeys = await this.connectionKeyRows(tenantId, scope?.vertical);
4899
+ if (!scope)
4900
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4901
+ const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
4902
+ const connectionKeys = await this.connectionKeyRows(tenantId, scope.vertical);
4488
4903
  await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, connectionKeys);
4489
4904
  }
4490
4905
  /**
@@ -4526,13 +4941,23 @@ export class CloudflareScopeHost {
4526
4941
  // rather than once for the tenant — memoized, because a fan-out over twenty scopes
4527
4942
  // of one vertical must not mint or re-read twenty times.
4528
4943
  const keysByVertical = new Map();
4529
- await Promise.all(scopes.map(async (s) => {
4944
+ // allSettled, not all (#1738): a scope that refuses (its `provisioned_for` receipt names
4945
+ // another tenant) must not stop its siblings converging, or one bad scope would freeze every
4946
+ // revoke for the tenant's healthy ones. The failure is still loud, after the rest have landed.
4947
+ const settled = await Promise.allSettled(scopes.map(async (s) => {
4530
4948
  const vertical = s.vertical ?? '';
4531
4949
  if (!keysByVertical.has(vertical)) {
4532
4950
  keysByVertical.set(vertical, this.connectionKeyRows(tenantId, s.vertical));
4533
4951
  }
4534
4952
  await this.scopeStub(s.scope_id).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, await keysByVertical.get(vertical));
4535
4953
  }));
4954
+ const failed = settled.flatMap((r, i) => (r.status === 'rejected' ? [{ scope: scopes[i].scope_id, reason: r.reason }] : []));
4955
+ if (failed.length === 0)
4956
+ return;
4957
+ // One aggregated error, so the caller learns which scopes did not converge and that the
4958
+ // rest did.
4959
+ const named = failed.map((f) => `${f.scope}: ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`).join('; ');
4960
+ throw substratError('conflict', `projection reached ${scopes.length - failed.length} of ${scopes.length} scopes of tenant ${tenantId}; not converged — ${named}`);
4536
4961
  }
4537
4962
  /**
4538
4963
  * Re-project a tenant's full state into every one of its scopes — the
@@ -4556,7 +4981,7 @@ export class CloudflareScopeHost {
4556
4981
  async provisionScopeLocal(input) {
4557
4982
  const stub = this.scopeStub(input.scopeId);
4558
4983
  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
4984
+ 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
4985
  // Only PROJECT (and thereby switch on strict enforcement) when the platform actually
4561
4986
  // supplies entitlements. Omitting them leaves the scope un-projected and trusting-
4562
4987
  // upstream, so a vertical whose provision path predates #304 is never denied — it
@@ -4645,7 +5070,13 @@ export class CloudflareScopeHost {
4645
5070
  key_id: k.keyId,
4646
5071
  public_key: k.publicKey,
4647
5072
  }))
5073
+ : undefined,
5074
+ // #1742: the recorded-off modules, switched off after the seat in the same unit, on the
5075
+ // scope this call provisions — the one the seat's tuples name.
5076
+ input.switchedOff
5077
+ ? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString() }
4648
5078
  : undefined);
5079
+ return input.switchedOff ? { switchedOff } : {};
4649
5080
  }
4650
5081
  /**
4651
5082
  * Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
@@ -4776,7 +5207,8 @@ export class CloudflareScopeHost {
4776
5207
  async systemSwitchLocal(scopeId, moduleId, to) {
4777
5208
  // Parsed on the way out: this is the wire answer the platform reads, and the DO's
4778
5209
  // 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()));
5210
+ // #1819: `switchInScope` releases a rewind's hold on the module once the switch lands.
5211
+ return systemSwitchOutcome.parse(await this.switchInScope(scopeId, moduleId, to, new Date().toISOString()));
4780
5212
  }
4781
5213
  /**
4782
5214
  * The far end of the status read for a scope served HERE (#1674): `/internal/system-grants`