@substrat-run/adapter-cloudflare 0.136.0 → 0.137.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,8 +1,9 @@
1
1
  import { env as ambientEnv } from 'cloudflare:workers';
2
2
  import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
3
- 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, capabilityFilter, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
3
+ import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
4
+ 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, mintedCapability, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
4
5
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
5
- import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, startJobRun, attachmentTextJob, assertAttachmentExtractors, assertJobRegistrable, isAttachmentTextRun, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, } from '@substrat-run/kernel';
6
+ import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, assertJobRegistrable, isAttachmentTextRun, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, } from '@substrat-run/kernel';
6
7
  import { attributedHost } from '@substrat-run/kernel';
7
8
  import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
8
9
  import { tenantStoreDatabaseName } from './d1.js';
@@ -37,6 +38,28 @@ function unwrapCapabilityReply(reply) {
37
38
  * clock; the skew allows for clocks that disagree, and bounds what a forged answer can move.
38
39
  */
39
40
  export const LIFECYCLE_EPOCH_SKEW_MS = 24 * 60 * 60 * 1000;
41
+ /**
42
+ * #2016: how many served scopes one heal pass asks for their tenant record, beyond the drift and the
43
+ * holds. Rotated least-recently-asked first, so a deployment that cannot answer yet (a build from
44
+ * before the record) costs at most this many deliveries a pass and starves nothing.
45
+ */
46
+ export const TENANT_UNRECORDED_PER_PASS = 50;
47
+ /**
48
+ * K-3's refusal of a (tenant, scope) pair the host does not hold — the same answer for a scope that
49
+ * does not exist and for one of another tenant (#1718), in one wording.
50
+ */
51
+ const unknownScopeForTenant = (tenantId, scopeId) => substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
52
+ /**
53
+ * #2016: the line a CP-less host writes each time it admits a pair to a scope with no tenant record
54
+ * and no role rows — nothing to hold the pair against. Ids only.
55
+ */
56
+ export const tenantUnrecordedLine = (tenantId, scopeId) => ({
57
+ substrat: 'tenant-unrecorded',
58
+ level: 'warn',
59
+ tenantId,
60
+ scopeId,
61
+ message: 'admitted to a scope whose tenant is not recorded; a reconcile or lifecycle delivery records it',
62
+ });
40
63
  /**
41
64
  * Refuse to mark a scope a copy unless the request classifies it non-primary (#2005). The
42
65
  * classification is the platform directory's; the vertical applies the same `isPrimaryScope` to
@@ -51,9 +74,10 @@ function assertCopyLineage(lineage) {
51
74
  * A control-plane stand-in for a CP-less vertical (scope-local-permissions.md Phase 3).
52
75
  * The hot path a served scope actually touches becomes trust-the-upstream:
53
76
  * - `scopeAccessRefusal` / `setMigrationState` → no-op: the router gates the scope's
54
- * lifecycle + tenancy from the shared directory, and the vertical trusts the asserted
55
- * node rather than re-reading a directory it does not have. Its own half of the
56
- * lifecycle gate reads the scope's storage instead (`assertLive`, #1713).
77
+ * lifecycle + tenancy from the shared directory, and the vertical does not re-read a
78
+ * directory it does not have. Its own half of both gates reads the scope's storage
79
+ * instead (`validateScopeAccess`): the tenant the scope was provisioned for (#2016) and the lifecycle
80
+ * the platform delivered (#1713).
57
81
  * - `tenantHoldsEntitlement` → true: the SKU was enforced on the shared control plane
58
82
  * at provision (before `provisionInstance`), so a scope that EXISTS here was granted
59
83
  * it upstream — a single-vertical deployment holds its own entitlements by construction.
@@ -71,12 +95,14 @@ function nullControlPlane() {
71
95
  // #1674: the schedule switch's record is a DIRECTORY store, and a CP-less host has no
72
96
  // directory — the shared control plane that delegates here keeps it. Same posture as
73
97
  // `recordAdmin`: nothing to write, nothing recorded to read back.
74
- recordSystemSwitchedOff: noop,
98
+ recordSystemSwitchedOff: async () => null,
75
99
  recordSystemSwitchedOn: async () => null,
76
100
  restoreSystemSwitchRecord: noop,
77
101
  listSystemSwitches: async () => [],
78
102
  systemSwitchRecordsOf: async () => [],
79
103
  switchedOffModulesOf: async () => [],
104
+ // #1823: no tenant tuples here either — the platform reads them and sends `tenantHeld`.
105
+ tenantHeldSystemModules: async () => [],
80
106
  };
81
107
  return new Proxy({}, {
82
108
  get: (_t, prop) => typeof prop === 'string' && prop in passthrough
@@ -124,6 +150,20 @@ export const SWITCH_HOLD_EXTRA_WAITS = 3;
124
150
  * clock, against the releasing host's clock: minutes, not seconds.
125
151
  */
126
152
  export const SWITCH_HOLD_PENDING_MAX_MS = 5 * 60_000;
153
+ /**
154
+ * An OFF of a tenant-held module whose far end did not attest `deniesTenantGrants` (#1823): it
155
+ * switched, but its evaluator still authorizes the module's tenant-level grant on the scope.
156
+ * Carries the far end's outcome, so the operator's OFF knows whether there is a move to put back.
157
+ */
158
+ class UnattestedSwitchOff extends Error {
159
+ outcome;
160
+ constructor(scopeId, moduleId, outcome) {
161
+ super(`the deployment serving scope ${scopeId} predates the kill switch's tenant-grant denial (#1823): ` +
162
+ `module '${moduleId}' holds a tenant-level grant that its OFF would leave authorizing there. ` +
163
+ `Redeploy the vertical, then retry.`);
164
+ this.outcome = outcome;
165
+ }
166
+ }
127
167
  /**
128
168
  * The attachment bucket a host resolves when it was handed no resolver (#1995): the
129
169
  * platform's per-tenant binding, read off this script's own env, named by the one shared
@@ -543,7 +583,7 @@ export class CloudflareScopeHost {
543
583
  return {
544
584
  startOrJoin: (key, row) => stub.jobRunStartOrJoin(key.moduleId, key.job, key.instance, row),
545
585
  get: (id) => stub.jobRunById(id),
546
- due: (now, limit, afterId) => stub.jobRunsDue(now, limit, afterId),
586
+ dueKeys: (now, max) => stub.jobRunsDueKeys(now, max),
547
587
  list: (filter) => stub.jobRunList(filter),
548
588
  patch: (id, patch) => stub.jobRunPatch(id, patch),
549
589
  commitPass: (id, patch) => stub.jobCommitPass(id, patch),
@@ -585,7 +625,10 @@ export class CloudflareScopeHost {
585
625
  store: this.jobStore(scopeId),
586
626
  handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
587
627
  now: () => new Date().toISOString(),
588
- openScope: (run) => this.getSystemScope(run.module_id, tenantId, scopeId),
628
+ // #1834: the door is opened FOR this pass, so its "not now" is tied to this pass alone.
629
+ openScope: async (run, pass) => this.buildStub(tenantId, scopeId, undefined, undefined, await this.openSystemDoor(run.module_id, tenantId, scopeId, undefined, pass)),
630
+ // #1834: only this host's own door refusals defer a pass, recognised by identity.
631
+ deferral: (err, pass) => this.takeDoorWait(err, pass),
589
632
  maxPasses: options?.maxPasses,
590
633
  limit: options?.limit,
591
634
  });
@@ -665,7 +708,7 @@ export class CloudflareScopeHost {
665
708
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
666
709
  // K-3: a scope under another tenant is indistinguishable from one that does not exist.
667
710
  if (!rec)
668
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
711
+ throw unknownScopeForTenant(tenantId, scopeId);
669
712
  if (rec.status !== 'active' && rec.status !== 'provisioning') {
670
713
  throw new Error(`scope not migratable (status: ${rec.status}): ${scopeId}`);
671
714
  }
@@ -737,6 +780,31 @@ export class CloudflareScopeHost {
737
780
  async listCapabilitiesLocal(scopeId, filter) {
738
781
  return this.scopeStub(scopeId).listCapabilities(filter);
739
782
  }
783
+ /**
784
+ * The platform's `become` mint on the CP-less path (#1686) — `HostAdmin.mintCapability`'s
785
+ * ScopeDO half, for the deployment that serves the scope. Its one caller is an owner claim
786
+ * link, minted on the platform's instruction behind the vertical's platform-gated
787
+ * `/internal/owner-claim`; the control plane made the K-3 check and keeps the admin log before
788
+ * it called. Not a module verb: a module mints through `ctx.capabilities`, which mints `act`
789
+ * capabilities only. Same lifecycle gate as the exchange, so a held scope mints nothing.
790
+ */
791
+ async mintCapabilityLocal(tenantId, scopeId, input, actor) {
792
+ // Checked on this side too, as `HostAdmin.mintCapability` does: a typed refusal thrown
793
+ // across the RPC arrives as a bare message.
794
+ checkBecomeInput(input, new Date().toISOString());
795
+ await this.assertLive(tenantId, scopeId);
796
+ await this.migrateAndRecord(scopeId);
797
+ return mintedCapability.parse(await this.scopeStub(scopeId).mintBecomeCapability(input, actor));
798
+ }
799
+ /**
800
+ * The platform's revoke on the CP-less path (#1686) — how a re-minted claim link retires the
801
+ * previous one. True when the scope held the capability (revoked now, or already); false when
802
+ * it holds no such capability. No lifecycle gate: a revoke only narrows, so a held scope still
803
+ * takes one. Idempotent.
804
+ */
805
+ async revokeCapabilityLocal(scopeId, capabilityId, actor) {
806
+ return (await this.scopeStub(scopeId).revokeCapabilityAsPlatform(capabilityId, actor)) !== null;
807
+ }
740
808
  /**
741
809
  * Copy one scope's data into a fresh scope DO, entirely within THIS deployment —
742
810
  * the data half of an orchestrated snapshot (preview-and-snapshots.md §9). Like the
@@ -745,12 +813,24 @@ export class CloudflareScopeHost {
745
813
  * activation, version bind) stays on the control plane's side. Because source and
746
814
  * destination sit in the same SCOPE namespace, no scope bytes ever leave the
747
815
  * deployment — the §9 property the trust line rests on.
816
+ *
817
+ * #2016: `tenantId`, the tenant the platform snapshots for (absent from an older platform). The
818
+ * source must not be foreign to it — a copy never reads another tenant's scope — and the fork
819
+ * records it as its own receipt, not the source's.
748
820
  */
749
- async snapshotScopeLocal(sourceScopeId, destScopeId) {
821
+ async snapshotScopeLocal(sourceScopeId, destScopeId, tenantId) {
822
+ if (tenantId !== undefined && (await this.scopeStub(sourceScopeId).admission(tenantId)).verdict === 'foreign') {
823
+ throw unknownScopeForTenant(tenantId, sourceScopeId);
824
+ }
750
825
  const tables = await this.scopeStub(sourceScopeId).exportDump();
751
826
  // #2009: a snapshot is a fork by construction (the control plane's row names `forkedFrom`, so
752
827
  // the directory never calls it primary), and a load classifies nothing by itself: marked here.
753
- await this.scopeStub(destScopeId).importDump(tables, destScopeId, { sourceScopeId, exact: true, markCopy: true });
828
+ await this.scopeStub(destScopeId).importDump(tables, destScopeId, {
829
+ sourceScopeId,
830
+ exact: true,
831
+ markCopy: true,
832
+ provisionedFor: tenantId,
833
+ });
754
834
  return { tables: tables.length };
755
835
  }
756
836
  /**
@@ -767,18 +847,23 @@ export class CloudflareScopeHost {
767
847
  * #1722: `loadStamp`, the stamp a carry leaves on the copy it lands, for a fenced wipe later,
768
848
  * and `expect`, the marker the carry read here: the load is refused if the store moved since.
769
849
  * #2005: `markCopy` — the directory's classification of the scope, sent when it is not
770
- * primary; the restore then marks it a copy. Refused if it classifies a primary. */
850
+ * primary; the restore then marks it a copy. Refused if it classifies a primary.
851
+ * #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
852
+ * the load's own transaction, and a store provisioned for another tenant refuses the load. */
771
853
  opts) {
772
854
  if (opts?.markCopy)
773
855
  assertCopyLineage(opts.markCopy);
774
856
  const load = {
775
- switchOff: opts?.switchedOff ? { moduleIds: opts.switchedOff, at: new Date().toISOString() } : undefined,
857
+ switchOff: opts?.switchedOff
858
+ ? { moduleIds: opts.switchedOff, at: new Date().toISOString(), tenantHeld: opts.tenantHeld }
859
+ : undefined,
776
860
  sourceScopeId: opts?.sourceScopeId,
777
861
  exact: opts?.exact,
778
862
  loadStamp: opts?.loadStamp,
779
863
  markCopy: opts?.markCopy !== undefined,
864
+ provisionedFor: opts?.tenantId,
780
865
  };
781
- // Both refusals come back as values (a throw over the RPC carries only its message) and are
866
+ // The refusals come back as values (a throw over the RPC carries only its message) and are
782
867
  // thrown here, in the vertical's own isolate, so its route answers 409 or 412, not a fault.
783
868
  const out = await this.scopeStub(scopeId).importDumpChecked(tables, scopeId, {
784
869
  ...load,
@@ -789,6 +874,8 @@ export class CloudflareScopeHost {
789
874
  }
790
875
  if (out.refused === 'kept')
791
876
  throw substratError('conflict', KEPT_COPY_REFUSAL);
877
+ if (out.refused === 'tenant')
878
+ throw substratError('conflict', out.message);
792
879
  // A retry of a load that already committed (its answer was lost on the way back) is
793
880
  // refused by the marker that load itself moved. The store holding THIS request's stamp
794
881
  // says so: no other load writes it. Answered as applied, without `switchedOff`, so the
@@ -855,9 +942,16 @@ export class CloudflareScopeHost {
855
942
  * the vertical's `/internal/lifecycle`. Kept unless the scope already holds a newer one, so a
856
943
  * push that arrives late cannot undo a later transition. `applied` is what the platform's heal
857
944
  * sweep records as delivered; `changed` says the gate's answer moved.
945
+ *
946
+ * #2016: `tenantId`, the tenant the directory delivered it for (absent from an older platform).
947
+ * A scope provisioned for another tenant refuses the delivery (`conflict`) and stores nothing;
948
+ * a scope provisioned before the tenant receipt existed records it here, its role rows agreeing.
858
949
  */
859
- async setLifecycleLocal(scopeId, next) {
860
- return lifecycleDelivery.parse(await this.scopeStub(scopeId).setLifecycle(scopeLifecycle.parse(next)));
950
+ async setLifecycleLocal(scopeId, next, tenantId) {
951
+ const out = await this.scopeStub(scopeId).setLifecycle(scopeLifecycle.parse(next), tenantId);
952
+ if ('refused' in out)
953
+ throw substratError('conflict', out.message);
954
+ return lifecycleDelivery.parse(out);
861
955
  }
862
956
  /**
863
957
  * Mark one scope in THIS deployment a copy (#2005), behind the vertical's `/internal/mark-copy`:
@@ -1145,8 +1239,15 @@ export class CloudflareScopeHost {
1145
1239
  // after it then finds them off, and audits what the unit moved.
1146
1240
  const ownStore = this.cpLess || record.vertical === null;
1147
1241
  const recordedOff = ownStore ? await this.cp.switchedOffModulesOf(input.tenantId, input.scopeId) : [];
1242
+ const seatAt = new Date().toISOString();
1148
1243
  const switchedOff = await this.scopeStub(input.scopeId).seatTuples(seats, recordedOff.length
1149
- ? { scopeId: input.scopeId, moduleIds: recordedOff, at: new Date().toISOString() }
1244
+ ? {
1245
+ scopeId: input.scopeId,
1246
+ moduleIds: recordedOff,
1247
+ at: seatAt,
1248
+ // #1823: a recorded-off module held only by a tenant grant goes back off too.
1249
+ tenantHeld: await this.cp.tenantHeldSystemModules(input.tenantId, recordedOff, seatAt),
1250
+ }
1150
1251
  : undefined);
1151
1252
  if (ownStore) {
1152
1253
  await this.admin.reassertSystemSwitches(actor, { tenantId: input.tenantId, scopeId: input.scopeId }, { appliedInUnit: switchedOff });
@@ -1292,15 +1393,15 @@ export class CloudflareScopeHost {
1292
1393
  return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
1293
1394
  }
1294
1395
  async getSystemAttachments(moduleId, tenantId, scopeId) {
1295
- // Reuse the system invoke door's module, tenant and lifecycle gate. The DO
1296
- // authorizes each open against the attachment target's read permission as
1396
+ // Reuse the system invoke door's module, tenant and lifecycle gate, and its rewind gate
1397
+ // (#1834). The DO authorizes each open against the attachment target's read permission as
1297
1398
  // system:<moduleId>; the worker reads bytes only after that gate succeeds.
1298
- await this.getSystemScope(moduleId, tenantId, scopeId);
1399
+ const door = await this.openSystemDoor(moduleId, tenantId, scopeId);
1299
1400
  const store = await this.resolveAttachmentStore(tenantId);
1300
1401
  const stub = this.scopeStub(scopeId);
1301
1402
  return {
1302
1403
  open: async (attachmentId) => {
1303
- const record = await stub.systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId);
1404
+ const record = await door.through((instance) => stub.systemAttachmentAuthorize(attachmentId, moduleId, tenantId, scopeId, instance));
1304
1405
  return record ? this.openAttachmentBytes(store, scopeId, record) : null;
1305
1406
  },
1306
1407
  };
@@ -1506,8 +1607,16 @@ export class CloudflareScopeHost {
1506
1607
  // restored in its deployment, which the platform carries the same list to.
1507
1608
  const ownStore = this.cpLess || existing.vertical === null;
1508
1609
  const recordedOff = ownStore ? await this.cp.switchedOffModulesOf(tenantId, scopeId) : [];
1610
+ const replayAt = new Date().toISOString();
1509
1611
  const switchedOff = await this.scopeStub(scopeId).importDump(dump.tables, scopeId, {
1510
- switchOff: recordedOff.length ? { moduleIds: recordedOff, at: new Date().toISOString() } : undefined,
1612
+ switchOff: recordedOff.length
1613
+ ? {
1614
+ moduleIds: recordedOff,
1615
+ at: replayAt,
1616
+ // #1823: a recorded-off module held only by a tenant grant goes back off too.
1617
+ tenantHeld: await this.cp.tenantHeldSystemModules(tenantId, recordedOff, replayAt),
1618
+ }
1619
+ : undefined,
1511
1620
  sourceScopeId: opts?.sourceScopeId ?? dump.scopeId,
1512
1621
  });
1513
1622
  await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
@@ -1685,12 +1794,93 @@ export class CloudflareScopeHost {
1685
1794
  * against `system:<moduleId>` grants inside the stub.
1686
1795
  */
1687
1796
  async getSystemScope(moduleId, tenantId, scopeId) {
1797
+ return this.buildStub(tenantId, scopeId, undefined, undefined, await this.openSystemDoor(moduleId, tenantId, scopeId));
1798
+ }
1799
+ /**
1800
+ * #1834: the system door — the ONE place every entry acting as `system:<moduleId>` passes
1801
+ * through: a schedule's fire, a job run's `pass.scope()`, a module's attachment open. The
1802
+ * module must be registered here and the scope live, as before. Then the door GATES: the
1803
+ * scope's own state read for the module, and after it the rewind hold (#1819), in the order
1804
+ * `SWITCH_HOLD_SETTLE_MS` relies on. A module the hold keeps off is refused here, as a
1805
+ * schedule pass skips it.
1806
+ *
1807
+ * A gate read is about the instance that answered it, and a door can be held for as long as
1808
+ * a job pass runs. So every call through the door is PINNED to that instance: the DO refuses
1809
+ * one that lands on another (`SYSTEM_DOOR_MOVED`), since a PITR restore always restarts the
1810
+ * object, and the door gates again and retries. A restart within one call's round trip is
1811
+ * rare, so past `SYSTEM_DOOR_REGATES` it fails closed (`unavailable`). No clock is involved:
1812
+ * the gate and the call meet the same storage, or the call does not run.
1813
+ */
1814
+ async openSystemDoor(moduleId, tenantId, scopeId, gated,
1815
+ /** #1834: the job pass this door is opened for; its "not now" refusals are tied to it. */
1816
+ pass) {
1688
1817
  if (!this.moduleIds.has(moduleId)) {
1689
1818
  throw new Error(`module not registered on this host: ${moduleId}`);
1690
1819
  }
1691
1820
  await this.assertLive(tenantId, scopeId);
1692
1821
  await this.migrateAndRecord(scopeId);
1693
- return this.buildStub(tenantId, scopeId, undefined, undefined, moduleId);
1822
+ // A gate this door reads itself reports a hold it could not read; one handed in was the
1823
+ // caller's to report (`runDueSchedules` puts it on its pass report).
1824
+ const regate = async () => {
1825
+ const fresh = await this.systemDoorGate(scopeId, moduleId);
1826
+ if (fresh.error)
1827
+ console.error(`substrat: system door ${moduleId} on ${scopeId}: ${fresh.error}`);
1828
+ return fresh;
1829
+ };
1830
+ let gate = gated ?? (await regate());
1831
+ return {
1832
+ moduleId,
1833
+ through: async (call) => {
1834
+ for (let regates = 0;; regates += 1) {
1835
+ if (gate.held) {
1836
+ // The hold ends when the switch is applied again: a job run waits it out without
1837
+ // spending its retries, because the driver defers on THIS refusal (`doorWait`).
1838
+ throw this.doorWait(pass, substratError('forbidden', `module '${moduleId}' is held off on this scope: it was rewound to before its schedule ` +
1839
+ 'switch was turned off, and it stays off until the switch is applied again', { reason: SYSTEM_DOOR_WAIT }));
1840
+ }
1841
+ const answer = await call(gate.instance);
1842
+ if (!isSystemDoorMoved(answer))
1843
+ return answer;
1844
+ if (regates >= SYSTEM_DOOR_REGATES) {
1845
+ throw this.doorWait(pass, substratError('unavailable', `the scope kept restarting under the system door of module '${moduleId}' ` +
1846
+ `(${regates + 1} attempts); nothing ran. Retry later`, { reason: SYSTEM_DOOR_WAIT }));
1847
+ }
1848
+ gate = await regate();
1849
+ }
1850
+ },
1851
+ };
1852
+ }
1853
+ /**
1854
+ * #1834: the refusals this host's system door threw that mean "not now" (a hold, a scope that
1855
+ * kept restarting), each tied by identity to the job pass whose door threw it. The job driver
1856
+ * defers a pass only on one of these, never on an error's shape, which any step or operation
1857
+ * could copy. A mark is ORIGIN and FRESHNESS together: it is consumed when the driver takes it,
1858
+ * and only the pass it names can take it, so a handler that keeps a refusal and throws it again
1859
+ * on a later pass is an ordinary failure there. Held weakly: an error nobody holds goes.
1860
+ */
1861
+ doorWaits = new WeakMap();
1862
+ /** #1834: mark this door's own refusal as "not now" for the pass it was opened for, and hand it back. */
1863
+ doorWait(pass, err) {
1864
+ if (pass)
1865
+ this.doorWaits.set(err, pass);
1866
+ return err;
1867
+ }
1868
+ /** #1834: is `err` a "not now" this host's door threw on `pass`? Consumes the mark when it is. */
1869
+ takeDoorWait(err, pass) {
1870
+ if (typeof err !== 'object' || err === null || this.doorWaits.get(err) !== pass)
1871
+ return false;
1872
+ this.doorWaits.delete(err);
1873
+ return true;
1874
+ }
1875
+ /**
1876
+ * #1834: the system door's gate — the scope's state read for the module, the instance that
1877
+ * answered it, and then (only for a module the scope has on) the rewind hold (#1819). The
1878
+ * hold is read AFTER the state, which is the order `switchHeld` needs.
1879
+ */
1880
+ async systemDoorGate(scopeId, moduleId) {
1881
+ const { state, instance } = await this.scopeStub(scopeId).systemDoorState(moduleId);
1882
+ const hold = state === 'on' ? await this.switchHeld(scopeId, moduleId) : { held: false };
1883
+ return { state, instance, held: hold.held, ...(hold.error ? { error: hold.error } : {}) };
1694
1884
  }
1695
1885
  /**
1696
1886
  * #1705: the consumer half. The same lifecycle gate as every door (K-3), then the ScopeDO
@@ -1772,7 +1962,7 @@ export class CloudflareScopeHost {
1772
1962
  // provisioning receipt is K-3 here (#1738), read before anything migrates the DO, and the
1773
1963
  // refusal reads as the pure host's does: an unknown scope, not an unknown role.
1774
1964
  if (this.cpLess && !(await this.scopeStub(scopeId).servesTenant(tenantId))) {
1775
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
1965
+ throw unknownScopeForTenant(tenantId, scopeId);
1776
1966
  }
1777
1967
  await this.peerScopeGate(tenantId, scopeId, 'canAssign');
1778
1968
  const bound = await this.scopeStub(scopeId).canAssignFor(tenantId, scopeId, principalId.parse(principal), roleKey);
@@ -1782,7 +1972,7 @@ export class CloudflareScopeHost {
1782
1972
  }
1783
1973
  async assignScopeRoleBounded(tenantId, scopeId, caller, assignee, roleKey) {
1784
1974
  if (this.cpLess && !(await this.scopeStub(scopeId).servesTenant(tenantId))) {
1785
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
1975
+ throw unknownScopeForTenant(tenantId, scopeId);
1786
1976
  }
1787
1977
  await this.peerScopeGate(tenantId, scopeId, 'assignScopeRoleBounded');
1788
1978
  const bound = await this.scopeStub(scopeId).assignScopeRoleBoundedFor(tenantId, scopeId, principalId.parse(caller), principalId.parse(assignee), roleKey);
@@ -1807,7 +1997,7 @@ export class CloudflareScopeHost {
1807
1997
  if (!this.cpLess) {
1808
1998
  const record = await this.cp.getScopeRecord(tenantId, scopeId);
1809
1999
  if (!record) {
1810
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2000
+ throw unknownScopeForTenant(tenantId, scopeId);
1811
2001
  }
1812
2002
  this.assertServedHere(record, scopeId, verb);
1813
2003
  }
@@ -1875,7 +2065,7 @@ export class CloudflareScopeHost {
1875
2065
  async capabilityScopeStub(tenantId, scopeId, verb) {
1876
2066
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
1877
2067
  if (!rec)
1878
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2068
+ throw unknownScopeForTenant(tenantId, scopeId);
1879
2069
  if (this.servesScopesElsewhereNow && rec.vertical !== null) {
1880
2070
  throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${rec.vertical}' ` +
1881
2071
  'deployment, and platform capability verbs are not delegated there yet');
@@ -1921,7 +2111,9 @@ export class CloudflareScopeHost {
1921
2111
  if (!rec || rec.status !== 'active')
1922
2112
  return report;
1923
2113
  }
1924
- else if (await this.lifecycleHeld(scopeId)) {
2114
+ else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
2115
+ // #2016: the pair is held to the scope's own record first, so a sweep roster entry whose
2116
+ // tenant disagrees with the scope is refused before the probe reads or writes anything.
1925
2117
  // #1713: a held scope emits nothing, so judging it stale would only raise a false alarm.
1926
2118
  return report;
1927
2119
  }
@@ -1963,13 +2155,15 @@ export class CloudflareScopeHost {
1963
2155
  return report;
1964
2156
  // Only run on a live scope of this tenant; a scope archived between the sweep's
1965
2157
  // enumeration and here simply has nothing due. A CP-less host has no directory
1966
- // to ask (#461), so it reads the lifecycle the platform delivered to the scope (#1713).
2158
+ // to ask (#461), so it reads the lifecycle the platform delivered to the scope (#1713), in
2159
+ // the same call that holds the pair to the scope's own record (#2016): a roster entry whose
2160
+ // tenant disagrees is refused before a grant, a cadence row or a run is read or written.
1967
2161
  if (!this.cpLess) {
1968
2162
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
1969
2163
  if (!rec || rec.status !== 'active')
1970
2164
  return report;
1971
2165
  }
1972
- else if (await this.lifecycleHeld(scopeId)) {
2166
+ else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
1973
2167
  // #1713: the lifecycle the platform delivered holds the scope. Every schedule is
1974
2168
  // `skipped` and no cadence row moves, as under the kill switch, so a schedule that
1975
2169
  // came due meanwhile fires once on the first pass after the scope is live again.
@@ -1986,15 +2180,15 @@ export class CloudflareScopeHost {
1986
2180
  // never held the module's grant (a foreign vertical's) is a quiet no-op, exactly as
1987
2181
  // before. A scope switched OFF reports every schedule `skipped`, never `failed`, and
1988
2182
  // does not touch its cadence rows — so a restore fires a due schedule on the next pass.
1989
- const state = await stub.systemScheduleState(moduleId);
1990
- if (state === 'ungranted')
2183
+ // #1819: a scope rewound to before its switch was pulled answers `on`, and the rewind held
2184
+ // the module outside the scope. Both are read by the system door's own gate (#1834), which
2185
+ // every fire below then goes through, pinned to the instance this read came from.
2186
+ const gate = await this.systemDoorGate(scopeId, moduleId);
2187
+ if (gate.state === 'ungranted')
1991
2188
  return report;
1992
- // #1819: a scope rewound to before its switch was pulled answers `on`. The rewind held the
1993
- // module outside the scope; read AFTER the state, which is the order `switchHeld` needs.
1994
- const hold = state === 'on' ? await this.switchHeld(scopeId, moduleId) : { held: false };
1995
- if (hold.error)
1996
- report.errors.push({ operation: 'switch-hold', error: hold.error });
1997
- if (state === 'off' || hold.held) {
2189
+ if (gate.error)
2190
+ report.errors.push({ operation: 'switch-hold', error: gate.error });
2191
+ if (gate.state === 'off' || gate.held) {
1998
2192
  for (const schedule of schedules) {
1999
2193
  report.skipped += 1;
2000
2194
  report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
@@ -2004,6 +2198,7 @@ export class CloudflareScopeHost {
2004
2198
  }
2005
2199
  const now = Date.now();
2006
2200
  const lines = asyncLinePass();
2201
+ let door;
2007
2202
  for (const schedule of schedules) {
2008
2203
  const last = await stub.scheduleLastRun(schedule.operation);
2009
2204
  const lastRun = last ? Date.parse(last) : null;
@@ -2023,7 +2218,10 @@ export class CloudflareScopeHost {
2023
2218
  let emitted;
2024
2219
  let failure;
2025
2220
  try {
2026
- const scope = await this.getSystemScope(moduleId, tenantId, scopeId);
2221
+ // The gate above already answered for this pass; a fire that meets a restarted scope
2222
+ // is gated again by the door (#1834).
2223
+ door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
2224
+ const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door);
2027
2225
  await scope.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
2028
2226
  report.fired += 1;
2029
2227
  }
@@ -2058,7 +2256,12 @@ export class CloudflareScopeHost {
2058
2256
  return report;
2059
2257
  }
2060
2258
  /** The stub body, shared by the principal, connection, system and impersonation doors. */
2061
- buildStub(tenantId, scopeId, principal, connectionId, systemModuleId, options,
2259
+ buildStub(tenantId, scopeId, principal, connectionId,
2260
+ /**
2261
+ * #1834: the system door this stub acts through. The ONLY way to make a stub acting as
2262
+ * `system:<moduleId>`: the module is the door's, and every invoke is gated and pinned by it.
2263
+ */
2264
+ systemDoor, options,
2062
2265
  /**
2063
2266
  * K-42: the session this stub acts under. Re-read from the directory on
2064
2267
  * EVERY invoke rather than captured here — a stub is a capability and
@@ -2079,6 +2282,7 @@ export class CloudflareScopeHost {
2079
2282
  */
2080
2283
  verticalCaller) {
2081
2284
  const stub = this.scopeStub(scopeId);
2285
+ const systemModuleId = systemDoor?.moduleId;
2082
2286
  const cp = this.cp;
2083
2287
  const operationEntitlement = this.operationEntitlement;
2084
2288
  // The DO needs SOME principal-shaped value for `ctx.principal`; for a
@@ -2133,7 +2337,15 @@ export class CloudflareScopeHost {
2133
2337
  }
2134
2338
  // K-42: resolved per invoke, so expiry and `endImpersonation` both bite.
2135
2339
  const session = sessionId === undefined ? undefined : await this.resolveImpersonation(sessionId, tenantId, scopeId);
2136
- const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session, capabilitySession, verticalCaller);
2340
+ const send = (systemDoorInstance) => stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session, capabilitySession, verticalCaller, systemDoorInstance);
2341
+ // #1834: through the system door, pinned to the instance its gate read. The DO's
2342
+ // refusal comes back as a failure envelope, so it is rethrown for the door to re-gate.
2343
+ const envelope = systemDoor
2344
+ ? await systemDoor.through(async (instance) => {
2345
+ const sent = await send(instance);
2346
+ return sent.systemDoorMoved === true ? SYSTEM_DOOR_MOVED : sent;
2347
+ })
2348
+ : await send();
2137
2349
  // The operation failed and the DO handed the error back as DATA — so it still
2138
2350
  // has its code and extensions, which a throw across this boundary would have
2139
2351
  // stripped down to a message (#113 §3). Rethrown here, where the caller expects
@@ -2182,6 +2394,12 @@ export class CloudflareScopeHost {
2182
2394
  throw substratError('unavailable', `${operation} ran on a scope host that did not understand peer callers. ` +
2183
2395
  'Retry once the scope has been migrated');
2184
2396
  }
2397
+ // #1834, the same shape: a DO too old to know the system door's pin ran the call
2398
+ // without checking it met the storage the door's gate read.
2399
+ if (systemDoor !== undefined && envelope.systemDoor?.honoured !== true) {
2400
+ throw substratError('unavailable', `${operation} ran on a scope host that did not understand the system door's pin. ` +
2401
+ 'Retry once the scope has been migrated');
2402
+ }
2185
2403
  if (session !== undefined && envelope.impersonation?.honoured !== true) {
2186
2404
  throw substratError('unavailable', `${operation} ran on a scope host that did not understand impersonation — the ` +
2187
2405
  'operation may have run unrecorded and unbounded. Retry once the scope has ' +
@@ -2414,7 +2632,7 @@ export class CloudflareScopeHost {
2414
2632
  if (!this.cpLess) {
2415
2633
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2416
2634
  if (!rec)
2417
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2635
+ throw unknownScopeForTenant(tenantId, scopeId);
2418
2636
  vertical = rec.vertical;
2419
2637
  }
2420
2638
  const action = to === 'off' ? 'revokeFromPeer' : 'restoreToPeer';
@@ -2484,21 +2702,50 @@ export class CloudflareScopeHost {
2484
2702
  if (!this.cpLess) {
2485
2703
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2486
2704
  if (!rec)
2487
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2705
+ throw unknownScopeForTenant(tenantId, scopeId);
2488
2706
  vertical = rec.vertical;
2489
2707
  }
2490
2708
  const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
2491
2709
  // #1819: a scope whose store is here was rewound here, so `switchInScope` releases its
2492
2710
  // hold here too. A delegated move releases in the deployment serving the scope.
2493
- const move = (moduleId, to, at) => delegation
2494
- ? delegation.switch({ tenantId, scopeId, moduleId, to })
2495
- : this.switchInScope(scopeId, moduleId, to, at);
2496
- return { vertical, move };
2711
+ // #1823: a module whose only authority on the scope is a TENANT-level grant has nothing in
2712
+ // the scope's storage for the switch to find, so the directory says whether it is held.
2713
+ // A caller moving several modules reads them in one call and passes each answer in.
2714
+ // An OFF of a tenant-held module is refused, after the far end answers, unless that answer
2715
+ // attests the evaluator that denies a tenant-level grant (`deniesTenantGrants`). A
2716
+ // deployment built before #1823 drops `tenantHeld`, still answers `held: true` (the module's
2717
+ // scope-level grants were there to switch), and keeps authorizing the tenant-level grant —
2718
+ // so its `held` alone would have the platform record a scope off that is not. Every OFF the
2719
+ // platform drives moves through here: an operator's (`switchSystem`, which then puts the
2720
+ // switch back) and a re-assert's, which every carry ends with.
2721
+ const move = async (moduleId, to, at, tenantHeld) => {
2722
+ const held = tenantHeld ?? (await this.cp.tenantHeldSystemModules(tenantId, [moduleId], at)).length > 0;
2723
+ const outcome = await (delegation
2724
+ ? delegation.switch({ tenantId, scopeId, moduleId, to, tenantHeld: held })
2725
+ : this.switchInScope(scopeId, moduleId, to, at, held));
2726
+ if (to === 'off' && held && outcome.held && outcome.deniesTenantGrants !== true) {
2727
+ throw new UnattestedSwitchOff(scopeId, moduleId, outcome);
2728
+ }
2729
+ return outcome;
2730
+ };
2731
+ /**
2732
+ * Is the module switched off on the scope right now? Read from wherever `move` writes — the
2733
+ * deployment's status read or this scope's own — for a move that threw (#1823): the switch
2734
+ * may already have moved before the throw (`switchInScope` moves it, then works the rewind
2735
+ * hold), and then the directory's record must stay.
2736
+ */
2737
+ const isOff = async (moduleId) => {
2738
+ const rows = delegation
2739
+ ? await delegation.status({ tenantId, scopeId })
2740
+ : systemScheduleEntry.array().parse(await this.scopeStub(scopeId).systemGrantsStatus());
2741
+ return rows.some((r) => r.moduleId === moduleId && r.schedules === 'off');
2742
+ };
2743
+ return { vertical, move, isOff };
2497
2744
  };
2498
2745
  const switchSystem = async (actor, raw, to) => {
2499
2746
  const input = systemSwitch.parse(raw);
2500
2747
  const { tenantId, scopeId } = input.node;
2501
- const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
2748
+ const { vertical, move, isOff } = await systemSwitchTarget(tenantId, scopeId);
2502
2749
  // AUDIT FIRST (#1666 review): the intent row lands before anything moves, and the
2503
2750
  // outcome row after — every attempt, a repeat included. The scope's store and the
2504
2751
  // admin log are separate, so no order makes the pair atomic; this one fails toward
@@ -2509,37 +2756,37 @@ export class CloudflareScopeHost {
2509
2756
  const target = { tenantId, scopeId, vertical };
2510
2757
  const base = { operationId, moduleId: input.moduleId, schedules: to };
2511
2758
  await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
2512
- // The directory's record (#1674): ON before the scope moves, OFF after it held — see
2513
- // `recordSystemSwitchedOn` for why that order is the safe one.
2759
+ // The directory's record (#1674), written BEFORE the scope moves, both ways, and undone
2760
+ // if the move throws or holds nothing — see `recordSystemSwitchedOn` and (#1823)
2761
+ // `recordSystemSwitchedOff` for why that order is the safe one.
2514
2762
  const at = new Date().toISOString();
2515
2763
  const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
2516
2764
  const errorOf = (err) => (err instanceof Error ? err.message : String(err));
2517
- let prior = null;
2518
- if (to === 'on') {
2519
- try {
2520
- prior = await this.cp.recordSystemSwitchedOn(record);
2521
- }
2522
- catch (err) {
2523
- // Nothing has moved: fail the call here, audited, rather than switch a scope on
2524
- // whose record still says off (the next reconcile would switch it back off).
2525
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) }).catch(() => undefined);
2526
- throw err;
2527
- }
2765
+ let prior;
2766
+ try {
2767
+ prior = to === 'on' ? await this.cp.recordSystemSwitchedOn(record) : await this.cp.recordSystemSwitchedOff(record);
2768
+ }
2769
+ catch (err) {
2770
+ // Nothing has moved: fail the call here, audited. An ON must not switch a scope on
2771
+ // whose record still says off (the next reconcile would switch it back off), and an
2772
+ // OFF must not leave a scope off that the record does not know of (#1823).
2773
+ await this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) }).catch(() => undefined);
2774
+ throw err;
2528
2775
  }
2529
2776
  /**
2530
- * A record write AFTER the scope moved (review #5): retried once, and a failure of both
2531
- * is answered rather than swallowed — it lands on the outcome row as `recordError`, and
2532
- * the call does not report plain success. A lost OFF record is exactly what a wipe then
2533
- * turns into a module running again.
2777
+ * The record's undo, after the scope's move threw or held nothing: retried once, and a
2778
+ * failure of both is answered rather than swallowed — it lands on the outcome row as
2779
+ * `recordError`, and the call says so.
2534
2780
  */
2535
- const recordWrite = async (write) => {
2781
+ const undoRecord = async () => {
2782
+ const undo = () => this.cp.restoreSystemSwitchRecord(record, prior);
2536
2783
  try {
2537
- await write();
2784
+ await undo();
2538
2785
  return null;
2539
2786
  }
2540
2787
  catch {
2541
2788
  try {
2542
- await write();
2789
+ await undo();
2543
2790
  return null;
2544
2791
  }
2545
2792
  catch (err) {
@@ -2552,25 +2799,54 @@ export class CloudflareScopeHost {
2552
2799
  outcome = await move(input.moduleId, to, at);
2553
2800
  }
2554
2801
  catch (err) {
2555
- const recordError = prior ? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior)) : null;
2802
+ if (err instanceof UnattestedSwitchOff) {
2803
+ // The far end switched, and cannot hold it against the tenant-level grant. Refused:
2804
+ // ON gives back exactly what this OFF took (nothing, when it changed nothing), and
2805
+ // then the record goes back too — neither the scope nor the directory says off. An ON
2806
+ // that fails here leaves the scope off, and the readback rule below keeps its record.
2807
+ const putBack = err.outcome.changed
2808
+ ? await move(input.moduleId, 'on', new Date().toISOString(), true).then(() => true, () => false)
2809
+ : true;
2810
+ if (putBack) {
2811
+ const recordError = await undoRecord();
2812
+ await this.recordAdmin(actor, action, target, null, {
2813
+ ...base,
2814
+ phase: 'refused',
2815
+ error: err.message,
2816
+ ...(recordError ? { recordError } : {}),
2817
+ }).catch(() => undefined);
2818
+ throw substratError('precondition_failed', `${err.message} Nothing was switched` +
2819
+ (recordError ? `, but its directory record could not be put back (${recordError}).` : '.'));
2820
+ }
2821
+ }
2822
+ // A throw does not say the scope did not move: the move crosses the scope's DO and then
2823
+ // the rewind hold, and a throw from the second leaves the scope's switch moved. So the
2824
+ // record is undone only when the scope reads back in the OTHER position — an OFF that
2825
+ // reads not off, an ON that still reads off — and kept when it reads back as asked.
2826
+ // Undoing an ON that landed would put the record back to `off` beside a scope that is
2827
+ // on, and the next re-assert would switch the operator's ON back off. A read that fails
2828
+ // too counts as off, both ways, so what is left fails toward off: an OFF keeps its
2829
+ // record (never a scope switched off with no record — a wipe or restore would have
2830
+ // nothing to re-assert, and #1743's refusal nothing to read), and an ON is undone back
2831
+ // to `off`, which the next re-assert completes if the scope did move.
2832
+ const off = await isOff(input.moduleId).catch(() => true);
2833
+ const recordKept = off === (to === 'off');
2834
+ const recordError = recordKept ? null : await undoRecord();
2556
2835
  // Best effort: the original error is what the caller must see, and the intent row
2557
2836
  // already says an attempt was made.
2558
2837
  await this.recordAdmin(actor, action, target, null, {
2559
2838
  ...base,
2560
2839
  phase: 'failed',
2561
2840
  error: errorOf(err),
2841
+ ...(recordKept ? { recordKept: true } : {}),
2562
2842
  ...(recordError ? { recordError } : {}),
2563
2843
  }).catch(() => undefined);
2564
2844
  throw err;
2565
2845
  }
2566
- // OFF is recorded once the scope held it. A refused ON moved nothing, so its record
2567
- // write is undone: left `on`, the next reconcile of a wiped scope would leave the
2568
- // module running.
2569
- const recordError = to === 'off' && outcome.held
2570
- ? await recordWrite(() => this.cp.recordSystemSwitchedOff(record))
2571
- : to === 'on' && !outcome.held && prior
2572
- ? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior))
2573
- : null;
2846
+ // A call that held nothing moved nothing, so its record write is undone: left `off`, it
2847
+ // would switch the module off the day it is installed; left `on`, the next reconcile of
2848
+ // a wiped scope would leave the module running.
2849
+ const recordError = outcome.held ? null : await undoRecord();
2574
2850
  await this.recordAdmin(actor, action, target, null, {
2575
2851
  ...base,
2576
2852
  phase: outcome.held ? 'applied' : 'refused',
@@ -2583,11 +2859,6 @@ export class CloudflareScopeHost {
2583
2859
  `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
2584
2860
  (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
2585
2861
  }
2586
- if (recordError) {
2587
- throw substratError('unavailable', `module '${input.moduleId}' is switched ${to} on scope ${scopeId}, but the directory's record of it ` +
2588
- `could not be written (${recordError}) — repeat the call to record it; a wipe of this scope would ` +
2589
- `otherwise lose the switch`);
2590
- }
2591
2862
  return {
2592
2863
  operationId,
2593
2864
  moduleId: input.moduleId,
@@ -2651,7 +2922,7 @@ export class CloudflareScopeHost {
2651
2922
  if (!this.cpLess) {
2652
2923
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2653
2924
  if (!rec)
2654
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2925
+ throw unknownScopeForTenant(tenantId, scopeId);
2655
2926
  vertical = rec.vertical;
2656
2927
  }
2657
2928
  // A hosted scope (a vertical bound, on a non-cpLess host) has no local storage worth
@@ -2738,8 +3009,9 @@ export class CloudflareScopeHost {
2738
3009
  });
2739
3010
  }
2740
3011
  const results = [];
3012
+ const tenantHeld = new Set(modules.length ? await this.cp.tenantHeldSystemModules(tenantId, modules, at) : []);
2741
3013
  for (const moduleId of modules) {
2742
- const outcome = await move(moduleId, 'off', at);
3014
+ const outcome = await move(moduleId, 'off', at, tenantHeld.has(moduleId));
2743
3015
  if (outcome.changed) {
2744
3016
  await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
2745
3017
  operationId: ulid(),
@@ -2769,7 +3041,7 @@ export class CloudflareScopeHost {
2769
3041
  if (!this.cpLess) {
2770
3042
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2771
3043
  if (!rec)
2772
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3044
+ throw unknownScopeForTenant(tenantId, scopeId);
2773
3045
  vertical = rec.vertical;
2774
3046
  }
2775
3047
  if (!this.cpLess && vertical !== null && !this.peerSwitchDelegation) {
@@ -2968,6 +3240,13 @@ export class CloudflareScopeHost {
2968
3240
  await this.recordAccess(actor, 'listSystemSwitches', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
2969
3241
  return rows.map((r) => systemSwitchRecord.parse(r));
2970
3242
  },
3243
+ tenantHeldSystemModules: async (actor, tenantId, moduleIds) => {
3244
+ const held = moduleIds.length
3245
+ ? await this.cp.tenantHeldSystemModules(tenantId, moduleIds, new Date().toISOString())
3246
+ : [];
3247
+ await this.recordAccess(actor, 'tenantHeldSystemModules', { tenantId }, { moduleIds: [...moduleIds] }, held.length);
3248
+ return held;
3249
+ },
2971
3250
  reassertSystemSwitches: reassertSystemSwitchesOf,
2972
3251
  // #1672 — the platform's two capability verbs. Audited AFTER the write, on both, and
2973
3252
  // the failure each leaves is the safe one: a mint whose audit row did not land never
@@ -4738,14 +5017,35 @@ export class CloudflareScopeHost {
4738
5017
  * The getScope gate (control-plane.md §4.1/§4.2) — the pair check, then the tenant's and
4739
5018
  * the scope's lifecycle — thrown on THIS side of the RPC (#1718). The ControlPlaneDO
4740
5019
  * answers its refusal as data, because an error thrown there arrives here flattened, with
4741
- * no code; a record crosses intact. A CP-less host's null control plane answers nothing,
4742
- * so the gate passes: the router already made it from the shared directory.
5020
+ * no code; a record crosses intact.
5021
+ *
5022
+ * A CP-less host's null control plane answers nothing: the router made the pair check from
5023
+ * the shared directory. The scope's own storage then makes it again (#2016), against the
5024
+ * tenant it was provisioned for (`ScopeDO.admission`), so a vertical that passes a scope id
5025
+ * under the wrong tenant meets K-3's refusal at the door instead of relying on the permission
5026
+ * gate's grant data to deny it.
5027
+ *
5028
+ * Answers with the lifecycle the CP-less scope holds (#1713), read in the same DO call as the
5029
+ * pair check, so an `assertLive` door still costs one round-trip; a door that reads the scope
5030
+ * without the lifecycle half pays the one DO call the pair check needs. Null with a directory,
5031
+ * whose refusal already covered the lifecycle.
4743
5032
  */
4744
5033
  async validateScopeAccess(tenantId, scopeId) {
4745
5034
  const refusal = await this.cp.scopeAccessRefusal(tenantId, scopeId);
4746
- if (!refusal)
4747
- return;
4748
- throw refusal.code ? substratError(refusal.code, refusal.message) : new Error(refusal.message);
5035
+ if (refusal)
5036
+ throw refusal.code ? substratError(refusal.code, refusal.message) : new Error(refusal.message);
5037
+ if (!this.cpLess)
5038
+ return null;
5039
+ const { verdict, lifecycle } = await this.scopeStub(scopeId).admission(tenantId);
5040
+ if (verdict === 'foreign')
5041
+ throw unknownScopeForTenant(tenantId, scopeId);
5042
+ // A scope with no tenant record and no role rows has nothing to hold the pair against, so it is
5043
+ // let through — a compatibility state, not a steady one: the next reconcile (every push runs
5044
+ // one against the scopes behind it, #1172) or lifecycle delivery records the directory's tenant.
5045
+ // Logged on every admission, so an operator sees a scope that has not converged.
5046
+ if (verdict === 'unknown')
5047
+ console.warn(JSON.stringify(tenantUnrecordedLine(tenantId, scopeId)));
5048
+ return lifecycle;
4749
5049
  }
4750
5050
  /**
4751
5051
  * THE lifecycle gate (#1713) at every door that runs a scope's work: a request's stubs and
@@ -4763,17 +5063,10 @@ export class CloudflareScopeHost {
4763
5063
  * grants) keep `validateScopeAccess` alone: an operator still reads a suspended scope.
4764
5064
  */
4765
5065
  async assertLive(tenantId, scopeId) {
4766
- await this.validateScopeAccess(tenantId, scopeId);
4767
- const refusal = await this.cpLessRefusal(scopeId, { tenantId, scopeId });
5066
+ const refusal = lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId), { tenantId, scopeId });
4768
5067
  if (refusal)
4769
5068
  throw new Error(refusal);
4770
5069
  }
4771
- /** The delivered lifecycle's refusal on a CP-less host (#1713); always null with a directory. */
4772
- async cpLessRefusal(scopeId, ids) {
4773
- if (!this.cpLess)
4774
- return null;
4775
- return lifecycleRefusal(await this.scopeStub(scopeId).lifecycle(), ids);
4776
- }
4777
5070
  /**
4778
5071
  * Deliver the directory's lifecycle to the deployments serving the matching scopes (#1713):
4779
5072
  * one scope after its transition, a tenant's scopes after the tenant's, or the heal sweep's
@@ -4792,8 +5085,25 @@ export class CloudflareScopeHost {
4792
5085
  if (!delegation || this.cpLess)
4793
5086
  return report;
4794
5087
  let targets;
5088
+ // #2016: the heal also asks the served scopes whose deployment has not answered that it holds a
5089
+ // record of the scope's tenant, a copy as much as a primary (no push reconciles a copy), until
5090
+ // each answers that it does. A bounded slice per pass, after the drift and the holds.
5091
+ const askTenant = new Set();
4795
5092
  try {
4796
5093
  targets = await this.cp.lifecycleTargets(filter);
5094
+ if (filter.drift) {
5095
+ const unrecorded = await this.cp.lifecycleTargets({ unrecorded: true, limit: TENANT_UNRECORDED_PER_PASS });
5096
+ // Noted before asking, so an ask that fails (or a deployment that never answers) rotates
5097
+ // behind the scopes not asked yet, and the next pass reaches them.
5098
+ if (unrecorded.length > 0)
5099
+ await this.cp.recordTenantAsks(unrecorded.map((u) => u.scope_id), new Date().toISOString());
5100
+ const seen = new Set(targets.map((t) => t.scope_id));
5101
+ for (const u of unrecorded) {
5102
+ askTenant.add(u.scope_id);
5103
+ if (!seen.has(u.scope_id))
5104
+ targets.push(u);
5105
+ }
5106
+ }
4797
5107
  }
4798
5108
  catch (err) {
4799
5109
  console.error('substrat: could not read the scopes to deliver a lifecycle to (#1713)', err);
@@ -4815,8 +5125,10 @@ export class CloudflareScopeHost {
4815
5125
  // no lifecycle runs live. That keeps an activation, and every transition before a
4816
5126
  // deployment carries the route, from posting a delivery that changes nothing. After a
4817
5127
  // restore (epoch > 0) no receipt says nothing about what the deployment holds, so it is
4818
- // delivered. A HOLD is always delivered.
4819
- if (lifecycleRefusal(lifecycle) === null && (t.delivered === null ? t.epoch === 0 : t.delivered === lifecycleReceipt(lifecycle))) {
5128
+ // delivered. A HOLD is always delivered, and so is a scope the heal asks for its tenant record.
5129
+ if (!askTenant.has(scopeId) &&
5130
+ lifecycleRefusal(lifecycle) === null &&
5131
+ (t.delivered === null ? t.epoch === 0 : t.delivered === lifecycleReceipt(lifecycle))) {
4820
5132
  continue;
4821
5133
  }
4822
5134
  report.attempted += 1;
@@ -4874,7 +5186,7 @@ export class CloudflareScopeHost {
4874
5186
  }
4875
5187
  }
4876
5188
  // The receipt is what the scope HOLDS: a newer delivery it kept instead is the truth.
4877
- await this.cp.recordLifecycleReceipt(scopeId, lifecycleReceipt(answer.lifecycle), new Date().toISOString());
5189
+ await this.cp.recordLifecycleReceipt(scopeId, lifecycleReceipt(answer.lifecycle), new Date().toISOString(), answer.tenantRecorded === true);
4878
5190
  report.delivered += 1;
4879
5191
  }
4880
5192
  catch (err) {
@@ -4923,7 +5235,7 @@ export class CloudflareScopeHost {
4923
5235
  * points ask the directory themselves.
4924
5236
  */
4925
5237
  async lifecycleHeld(scopeId) {
4926
- return (await this.cpLessRefusal(scopeId)) !== null;
5238
+ return this.cpLess && lifecycleRefusal(await this.scopeStub(scopeId).lifecycle()) !== null;
4927
5239
  }
4928
5240
  /**
4929
5241
  * K-3's cross-check on its own: the (tenant, scope) pair must exist and agree before a
@@ -4933,7 +5245,7 @@ export class CloudflareScopeHost {
4933
5245
  async assertScope(tenantId, scopeId) {
4934
5246
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
4935
5247
  if (!rec)
4936
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5248
+ throw unknownScopeForTenant(tenantId, scopeId);
4937
5249
  }
4938
5250
  /**
4939
5251
  * The same K-3 cross-check, for a read that then opens the scope's STORAGE — every
@@ -4948,7 +5260,7 @@ export class CloudflareScopeHost {
4948
5260
  async scopeRecordForRead(tenantId, scopeId) {
4949
5261
  const row = await this.cp.getScopeRecord(tenantId, scopeId);
4950
5262
  if (!row)
4951
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5263
+ throw unknownScopeForTenant(tenantId, scopeId);
4952
5264
  if (row.status === 'reaped') {
4953
5265
  throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
4954
5266
  }
@@ -5229,7 +5541,7 @@ export class CloudflareScopeHost {
5229
5541
  * its claims before the OFF's join lands. The joined row then holds the module the operator just
5230
5542
  * turned ON, until the next surviving move of that switch releases it (another ON does).
5231
5543
  */
5232
- async switchInScope(scopeId, moduleId, to, at) {
5544
+ async switchInScope(scopeId, moduleId, to, at, tenantHeld = false) {
5233
5545
  let scopeClaims;
5234
5546
  let readError;
5235
5547
  try {
@@ -5238,7 +5550,7 @@ export class CloudflareScopeHost {
5238
5550
  catch (err) {
5239
5551
  readError = err;
5240
5552
  }
5241
- const { instance, ...outcome } = await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
5553
+ const { instance, ...outcome } = await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at, tenantHeld);
5242
5554
  if (!scopeClaims)
5243
5555
  throw readError;
5244
5556
  const now = Date.now();
@@ -5538,7 +5850,7 @@ export class CloudflareScopeHost {
5538
5850
  // good. Read first, so a pair the directory does not hold writes nothing.
5539
5851
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
5540
5852
  if (!scope)
5541
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
5853
+ throw unknownScopeForTenant(tenantId, scopeId);
5542
5854
  const { roles, tuples, entitlements, identities } = await this.tenantProjection(tenantId);
5543
5855
  const connectionKeys = await this.connectionKeyRows(tenantId, scope.vertical);
5544
5856
  await this.scopeStub(scopeId).applyProjection(tenantId, roles, tuples, entitlements, undefined, identities, connectionKeys);
@@ -5716,7 +6028,7 @@ export class CloudflareScopeHost {
5716
6028
  // #1742: the recorded-off modules, switched off after the seat in the same unit, on the
5717
6029
  // scope this call provisions — the one the seat's tuples name.
5718
6030
  input.switchedOff
5719
- ? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString() }
6031
+ ? { scopeId: input.scopeId, moduleIds: input.switchedOff, at: new Date().toISOString(), tenantHeld: input.tenantHeld }
5720
6032
  : undefined, services?.map((id) => `principal:${id}`));
5721
6033
  return input.switchedOff ? { switchedOff } : {};
5722
6034
  }
@@ -5865,11 +6177,11 @@ export class CloudflareScopeHost {
5865
6177
  * admin log and writes the row once this returns. `held: false` is an answer, not a
5866
6178
  * throw — see `systemSwitchOutcome`.
5867
6179
  */
5868
- async systemSwitchLocal(scopeId, moduleId, to) {
6180
+ async systemSwitchLocal(scopeId, moduleId, to, opts) {
5869
6181
  // Parsed on the way out: this is the wire answer the platform reads, and the DO's
5870
6182
  // plain strings become the published shape here rather than on trust.
5871
6183
  // #1819: `switchInScope` releases a rewind's hold on the module once the switch lands.
5872
- return systemSwitchOutcome.parse(await this.switchInScope(scopeId, moduleId, to, new Date().toISOString()));
6184
+ return systemSwitchOutcome.parse(await this.switchInScope(scopeId, moduleId, to, new Date().toISOString(), opts?.tenantHeld ?? false));
5873
6185
  }
5874
6186
  /**
5875
6187
  * The far end of the status read for a scope served HERE (#1674): `/internal/system-grants`
@@ -5970,7 +6282,7 @@ export class CloudflareScopeHost {
5970
6282
  }
5971
6283
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
5972
6284
  if (!rec)
5973
- throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
6285
+ throw unknownScopeForTenant(tenantId, scopeId);
5974
6286
  const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
5975
6287
  // Where the write lands is the peer switch's rule: a scope bound to a vertical is served by
5976
6288
  // that vertical's deployment, and its watermark lives there. Without a delegation that