@substrat-run/adapter-cloudflare 0.120.0 → 0.121.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,6 @@
1
- import { fromWireFailure, exportReadInput, exportedBatch, importBatch, importResult, importState, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, 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, 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 { 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, 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
2
  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, systemSwitchedOffMessage, CrossVerticalRegistry, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, } from '@substrat-run/kernel';
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, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, } from '@substrat-run/kernel';
4
4
  import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
5
5
  import { tenantStoreDatabaseName } from './d1.js';
6
6
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
@@ -31,7 +31,7 @@ function unwrapCapabilityReply(reply) {
31
31
  /**
32
32
  * A control-plane stand-in for a CP-less vertical (scope-local-permissions.md Phase 3).
33
33
  * The hot path a served scope actually touches becomes trust-the-upstream:
34
- * - `validateScopeAccess` / `setMigrationState` → no-op: the router already gated the
34
+ * - `scopeAccessRefusal` / `setMigrationState` → no-op: the router already gated the
35
35
  * scope's lifecycle + tenancy from the shared directory, so the vertical trusts the
36
36
  * asserted node rather than re-reading a directory it does not have.
37
37
  * - `tenantHoldsEntitlement` → true: the SKU was enforced on the shared control plane
@@ -43,11 +43,20 @@ function unwrapCapabilityReply(reply) {
43
43
  function nullControlPlane() {
44
44
  const noop = async () => undefined;
45
45
  const passthrough = {
46
- validateScopeAccess: noop,
46
+ scopeAccessRefusal: noop,
47
47
  setMigrationState: noop,
48
48
  recordAdmin: noop,
49
49
  recordAccess: noop,
50
50
  tenantHoldsEntitlement: async () => true,
51
+ // #1674: the schedule switch's record is a DIRECTORY store, and a CP-less host has no
52
+ // directory — the shared control plane that delegates here keeps it. Same posture as
53
+ // `recordAdmin`: nothing to write, nothing recorded to read back.
54
+ recordSystemSwitchedOff: noop,
55
+ recordSystemSwitchedOn: async () => null,
56
+ restoreSystemSwitchRecord: noop,
57
+ listSystemSwitches: async () => [],
58
+ systemSwitchRecordsOf: async () => [],
59
+ switchedOffModulesOf: async () => [],
51
60
  };
52
61
  return new Proxy({}, {
53
62
  get: (_t, prop) => typeof prop === 'string' && prop in passthrough
@@ -124,6 +133,8 @@ export class CloudflareScopeHost {
124
133
  systemSwitchDelegation;
125
134
  /** #1706: the peer kill switch's reach into the deployment serving a scope. */
126
135
  peerSwitchDelegation;
136
+ /** #1705 PR 3: the replay lever's reach into the deployment serving a consumer scope. */
137
+ importCursorDelegation;
127
138
  /**
128
139
  * MUST be constructed per request. Never cache an instance across requests.
129
140
  *
@@ -152,6 +163,7 @@ export class CloudflareScopeHost {
152
163
  this.eventDrainDelegation = options.eventDrainDelegation;
153
164
  this.systemSwitchDelegation = options.systemSwitchDelegation;
154
165
  this.peerSwitchDelegation = options.peerSwitchDelegation;
166
+ this.importCursorDelegation = options.importCursorDelegation;
155
167
  this.admin = this.buildAdmin();
156
168
  }
157
169
  // -- registration mechanics (validation only) -----------------------------
@@ -341,7 +353,7 @@ export class CloudflareScopeHost {
341
353
  async drainDue(tenantId, scopeId) {
342
354
  // Same lifecycle gate `getScope` applies (K-3): a suspended or archived scope
343
355
  // does not get its effects driven either.
344
- await this.cp.validateScopeAccess(tenantId, scopeId);
356
+ await this.validateScopeAccess(tenantId, scopeId);
345
357
  await this.migrateAndRecord(scopeId);
346
358
  // #1525: null, and honestly so — a sweep is not a call. An attempt this pass makes
347
359
  // records no invocation, which is what distinguishes it from the first attempt the
@@ -380,14 +392,14 @@ export class CloudflareScopeHost {
380
392
  };
381
393
  }
382
394
  async startJobRun(tenantId, scopeId, input) {
383
- await this.cp.validateScopeAccess(tenantId, scopeId);
395
+ await this.validateScopeAccess(tenantId, scopeId);
384
396
  await this.migrateAndRecord(scopeId);
385
397
  return jobRunOf(await startJobRun(this.jobStore(scopeId), input, ulid, () => new Date().toISOString()));
386
398
  }
387
399
  async runDueJobs(tenantId, scopeId, options) {
388
400
  // Same lifecycle gate as `drainDue`: a suspended scope's runs wait rather than
389
401
  // advance, and an archived one's never move again.
390
- await this.cp.validateScopeAccess(tenantId, scopeId);
402
+ await this.validateScopeAccess(tenantId, scopeId);
391
403
  await this.migrateAndRecord(scopeId);
392
404
  return runDueJobRuns({
393
405
  store: this.jobStore(scopeId),
@@ -399,7 +411,7 @@ export class CloudflareScopeHost {
399
411
  });
400
412
  }
401
413
  async jobRuns(tenantId, scopeId, filter) {
402
- await this.cp.validateScopeAccess(tenantId, scopeId);
414
+ await this.validateScopeAccess(tenantId, scopeId);
403
415
  await this.migrateAndRecord(scopeId);
404
416
  return (await this.jobStore(scopeId).list(filter ?? {})).map(jobRunOf);
405
417
  }
@@ -409,7 +421,7 @@ export class CloudflareScopeHost {
409
421
  // same context build as the in-process path, so the handler cannot tell which host
410
422
  // ran it. On a CP-less host `connectorContext` throws from the null control plane:
411
423
  // fail closed, exactly the hole routing exists to avoid.
412
- await this.cp.validateScopeAccess(tenantId, scopeId);
424
+ await this.validateScopeAccess(tenantId, scopeId);
413
425
  this.causedBy = event.id;
414
426
  try {
415
427
  await handler(await this.connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000, event.id), event);
@@ -419,7 +431,7 @@ export class CloudflareScopeHost {
419
431
  }
420
432
  }
421
433
  async executorDeadLetters(tenantId, scopeId) {
422
- await this.cp.validateScopeAccess(tenantId, scopeId);
434
+ await this.validateScopeAccess(tenantId, scopeId);
423
435
  await this.migrateAndRecord(scopeId);
424
436
  return this.scopeStub(scopeId).executorDeadLetters();
425
437
  }
@@ -432,18 +444,18 @@ export class CloudflareScopeHost {
432
444
  return this.scopeStub(scopeId).enqueueSweepRuns(JSON.stringify(sweepRunsPayload.parse(payload)), JSON.stringify({ system: 'scope-sweeper' }));
433
445
  }
434
446
  async listPlatformRequests(tenantId, scopeId) {
435
- await this.cp.validateScopeAccess(tenantId, scopeId);
447
+ await this.validateScopeAccess(tenantId, scopeId);
436
448
  await this.migrateAndRecord(scopeId);
437
449
  // Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
438
450
  return (await this.scopeStub(scopeId).pendingPlatformRequests()).map(platformRequestOf);
439
451
  }
440
452
  async listPlatformRequestHistory(tenantId, scopeId, filter) {
441
- await this.cp.validateScopeAccess(tenantId, scopeId);
453
+ await this.validateScopeAccess(tenantId, scopeId);
442
454
  await this.migrateAndRecord(scopeId);
443
455
  return (await this.scopeStub(scopeId).platformRequestHistory(filter)).map(platformRequestOf);
444
456
  }
445
457
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
446
- await this.cp.validateScopeAccess(tenantId, scopeId);
458
+ await this.validateScopeAccess(tenantId, scopeId);
447
459
  await this.migrateAndRecord(scopeId);
448
460
  await this.scopeStub(scopeId).settlePlatformRequest(id, outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure));
449
461
  }
@@ -473,7 +485,7 @@ export class CloudflareScopeHost {
473
485
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
474
486
  // K-3: a scope under another tenant is indistinguishable from one that does not exist.
475
487
  if (!rec)
476
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
488
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
477
489
  if (rec.status !== 'active' && rec.status !== 'provisioning') {
478
490
  throw new Error(`scope not migratable (status: ${rec.status}): ${scopeId}`);
479
491
  }
@@ -783,6 +795,13 @@ export class CloudflareScopeHost {
783
795
  for (const seat of peerSeats(collectPeers(this.peerSources), input.scopeId)) {
784
796
  await this.scopeStub(input.scopeId).seatTuple(seat.subject, seat.relation, seat.object, null);
785
797
  }
798
+ // #1674: re-assert the recorded OFF positions after the seat (`system-switch-record.ts`).
799
+ // Only where this seat landed in the scope's real store — a CP-less host, or a scope
800
+ // bound to no vertical. A scope a vertical's deployment serves is seated there, later,
801
+ // 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 });
804
+ }
786
805
  // Audit a real provision only; an idempotent re-provision changed nothing.
787
806
  if (created) {
788
807
  await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
@@ -918,7 +937,7 @@ export class CloudflareScopeHost {
918
937
  // gates and metadata facts live in the ScopeDO (per-scope serialization, spine event
919
938
  // in the same transaction); bytes go straight to the per-tenant R2 bucket through the
920
939
  // binding the vertical's worker resolved — never through the DO.
921
- await this.cp.validateScopeAccess(tenantId, scopeId);
940
+ await this.validateScopeAccess(tenantId, scopeId);
922
941
  await this.migrateAndRecord(scopeId);
923
942
  const store = await this.resolveAttachmentStore(tenantId);
924
943
  return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
@@ -934,12 +953,12 @@ export class CloudflareScopeHost {
934
953
  throw new Error(`connection ${connectionId} is revoked`);
935
954
  const scope = await this.cp.getScopeRecord(conn.tenant_id, scopeId);
936
955
  if (!scope)
937
- throw new Error(`unknown scope for connection: ${scopeId}`);
956
+ throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
938
957
  if (scope.vertical !== conn.vertical) {
939
958
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
940
959
  `runs '${scope.vertical ?? 'none'}'`);
941
960
  }
942
- await this.cp.validateScopeAccess(conn.tenant_id, scopeId);
961
+ await this.validateScopeAccess(conn.tenant_id, scopeId);
943
962
  // #574: same delegation as getConnectorScope. `upload` is the verb the reconcile
944
963
  // path needs (landing the sealed PDF) and `open` the one the outbound path needs
945
964
  // (sending the vertical's own document, #711). `list` and `remove` still fail
@@ -1100,14 +1119,14 @@ export class CloudflareScopeHost {
1100
1119
  // Restore never creates a scope (that is importScope) — an unknown target fails closed.
1101
1120
  const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1102
1121
  if (!existing)
1103
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
1122
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1104
1123
  await this.scopeStub(scopeId).importDump(dump.tables, scopeId);
1105
1124
  await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1106
1125
  }
1107
1126
  async snapshotScope(actor, tenantId, scopeId, opts) {
1108
1127
  const source = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1109
1128
  if (!source)
1110
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
1129
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1111
1130
  const dump = await this.admin.exportScope(actor, tenantId, scopeId);
1112
1131
  const snapshotId = ulid();
1113
1132
  await this.importScope(actor, {
@@ -1132,7 +1151,7 @@ export class CloudflareScopeHost {
1132
1151
  // #509 ask (b)). A PRIMARY scope keeps the platform's tombstone-only rule (archive it).
1133
1152
  const rec = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1134
1153
  if (!rec)
1135
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
1154
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1136
1155
  if (!rec.forkedFrom && rec.kind !== 'preview') {
1137
1156
  throw new Error(`scope ${scopeId} is not a fork or preview — only previews may be deleted; ` +
1138
1157
  `archive a primary scope instead`);
@@ -1188,7 +1207,7 @@ export class CloudflareScopeHost {
1188
1207
  async getScope(principal, tenantId, scopeId, options) {
1189
1208
  // Lifecycle gates (control-plane.md §4.1/§4.2), the K-3 fail-closed path,
1190
1209
  // evaluated durably in the ControlPlaneDO. A throw propagates here.
1191
- await this.cp.validateScopeAccess(tenantId, scopeId);
1210
+ await this.validateScopeAccess(tenantId, scopeId);
1192
1211
  await this.migrateAndRecord(scopeId);
1193
1212
  return this.buildStub(tenantId, scopeId, principal, undefined, undefined, options);
1194
1213
  }
@@ -1211,12 +1230,12 @@ export class CloudflareScopeHost {
1211
1230
  throw new Error(`connection ${connectionId} is revoked`);
1212
1231
  const scope = await this.cp.getScopeRecord(conn.tenant_id, scopeId);
1213
1232
  if (!scope)
1214
- throw new Error(`unknown scope for connection: ${scopeId}`);
1233
+ throw substratError('not_found', `unknown scope for connection: ${scopeId}`);
1215
1234
  if (scope.vertical !== conn.vertical) {
1216
1235
  throw new Error(`connection ${connectionId} is for vertical '${conn.vertical}' and scope ${scopeId} ` +
1217
1236
  `runs '${scope.vertical ?? 'none'}'`);
1218
1237
  }
1219
- await this.cp.validateScopeAccess(conn.tenant_id, scopeId);
1238
+ await this.validateScopeAccess(conn.tenant_id, scopeId);
1220
1239
  // #574: a scope served by ANOTHER deployment (the shared control plane running the
1221
1240
  // connector pass for a dispatch vertical) — the write-back rides the delegation
1222
1241
  // seam; migration is the serving deployment's business, exactly like provision.
@@ -1263,7 +1282,7 @@ export class CloudflareScopeHost {
1263
1282
  const record = await this.resolveImpersonation(session, tenantId, scopeId);
1264
1283
  // The same lifecycle gate the principal door applies: a suspended tenant
1265
1284
  // refuses a support session exactly as it refuses a user.
1266
- await this.cp.validateScopeAccess(tenantId, scopeId);
1285
+ await this.validateScopeAccess(tenantId, scopeId);
1267
1286
  await this.migrateAndRecord(scopeId);
1268
1287
  return this.buildStub(tenantId, scopeId, record.principal, undefined, undefined, options, record.id);
1269
1288
  }
@@ -1277,7 +1296,7 @@ export class CloudflareScopeHost {
1277
1296
  if (!this.moduleIds.has(moduleId)) {
1278
1297
  throw new Error(`module not registered on this host: ${moduleId}`);
1279
1298
  }
1280
- await this.cp.validateScopeAccess(tenantId, scopeId);
1299
+ await this.validateScopeAccess(tenantId, scopeId);
1281
1300
  await this.migrateAndRecord(scopeId);
1282
1301
  return this.buildStub(tenantId, scopeId, undefined, undefined, moduleId);
1283
1302
  }
@@ -1310,7 +1329,7 @@ export class CloudflareScopeHost {
1310
1329
  * capability row lives. The answer is re-parsed on this side of the RPC.
1311
1330
  */
1312
1331
  async exchangeCapability(tenantId, scopeId, secret, options) {
1313
- await this.cp.validateScopeAccess(tenantId, scopeId);
1332
+ await this.validateScopeAccess(tenantId, scopeId);
1314
1333
  await this.migrateAndRecord(scopeId);
1315
1334
  const outcome = await this.scopeStub(scopeId).exchangeCapability(secret, tenantId, scopeId, options?.mode);
1316
1335
  return capabilityExchange.nullable().parse(outcome);
@@ -1324,7 +1343,7 @@ export class CloudflareScopeHost {
1324
1343
  if (!plausibleSessionToken(sessionToken)) {
1325
1344
  throw substratError('unauthenticated', 'not a capability session token');
1326
1345
  }
1327
- await this.cp.validateScopeAccess(tenantId, scopeId);
1346
+ await this.validateScopeAccess(tenantId, scopeId);
1328
1347
  await this.migrateAndRecord(scopeId);
1329
1348
  const hash = await capabilityTokenHash(sessionToken);
1330
1349
  return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, hash);
@@ -1370,7 +1389,7 @@ export class CloudflareScopeHost {
1370
1389
  }
1371
1390
  this.assertServedHere(record, scopeId, verb);
1372
1391
  }
1373
- await this.cp.validateScopeAccess(tenantId, scopeId);
1392
+ await this.validateScopeAccess(tenantId, scopeId);
1374
1393
  await this.migrateAndRecord(scopeId);
1375
1394
  }
1376
1395
  /**
@@ -1383,7 +1402,7 @@ export class CloudflareScopeHost {
1383
1402
  if (!plausibleSessionToken(sessionToken)) {
1384
1403
  throw substratError('unauthenticated', 'not a capability session token');
1385
1404
  }
1386
- await this.cp.validateScopeAccess(tenantId, scopeId);
1405
+ await this.validateScopeAccess(tenantId, scopeId);
1387
1406
  await this.migrateAndRecord(scopeId);
1388
1407
  const store = await this.resolveAttachmentStore(tenantId);
1389
1408
  const capabilitySession = await capabilityTokenHash(sessionToken);
@@ -1407,13 +1426,21 @@ export class CloudflareScopeHost {
1407
1426
  * and by the verbs that read a record of their own, so a caller never meets two of them.
1408
1427
  */
1409
1428
  assertServedHere(record, scopeId, verb) {
1410
- if (this.servesScopesElsewhere && record.vertical !== null) {
1429
+ if (this.servesScopesElsewhereNow && record.vertical !== null) {
1411
1430
  throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${record.vertical}' deployment, ` +
1412
1431
  'which a peer reaches through the platform, not through the shared control plane');
1413
1432
  }
1414
1433
  }
1415
- get servesScopesElsewhere() {
1416
- return Boolean(this.connectorDelegation || this.systemSwitchDelegation || this.peerSwitchDelegation || this.eventDrainDelegation);
1434
+ /** `ScopeHost.servesScopesElsewhere` (#1705 PR 3): any delegation set means the shared control plane. */
1435
+ servesScopesElsewhere() {
1436
+ return this.servesScopesElsewhereNow;
1437
+ }
1438
+ get servesScopesElsewhereNow() {
1439
+ return Boolean(this.connectorDelegation ||
1440
+ this.systemSwitchDelegation ||
1441
+ this.peerSwitchDelegation ||
1442
+ this.eventDrainDelegation ||
1443
+ this.importCursorDelegation);
1417
1444
  }
1418
1445
  /**
1419
1446
  * Where a platform capability verb (#1672) may write: this host's own ScopeDO — refused,
@@ -1427,7 +1454,7 @@ export class CloudflareScopeHost {
1427
1454
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
1428
1455
  if (!rec)
1429
1456
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
1430
- if (this.servesScopesElsewhere && rec.vertical !== null) {
1457
+ if (this.servesScopesElsewhereNow && rec.vertical !== null) {
1431
1458
  throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${rec.vertical}' ` +
1432
1459
  'deployment, and platform capability verbs are not delegated there yet');
1433
1460
  }
@@ -1696,6 +1723,8 @@ export class CloudflareScopeHost {
1696
1723
  const enqueued = envelope.platformRequests + (drained.routedToPlatform ?? 0);
1697
1724
  if (enqueued > 0)
1698
1725
  options?.onPlatformRequests?.(enqueued);
1726
+ if ((envelope.exported ?? 0) > 0)
1727
+ options?.onExportedEvents?.(envelope.exported);
1699
1728
  if (envelope.concurrency)
1700
1729
  invokeOptions?.onEntityVersion?.(envelope.concurrency.version);
1701
1730
  if (envelope.idempotency?.replayed)
@@ -1744,7 +1773,22 @@ export class CloudflareScopeHost {
1744
1773
  ...(r.serving_version_id ? { servingVersionId: r.serving_version_id } : {}),
1745
1774
  createdAt: r.created_at,
1746
1775
  });
1747
- const mapVersion = (r) => verticalVersion.parse({
1776
+ // A listed version carries its surfaces as lifted JSON text, not its manifest (#1677):
1777
+ // read on exactly `outboundOfManifestJson`'s terms, so the two reads agree.
1778
+ const stringListOfJson = (json) => {
1779
+ if (!json)
1780
+ return null;
1781
+ try {
1782
+ const v = JSON.parse(json);
1783
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : null;
1784
+ }
1785
+ catch {
1786
+ return null;
1787
+ }
1788
+ };
1789
+ const mapVersion = (r) => versionRecord(r, outboundOfManifestJson(r.manifest_json), callsOfManifestJson(r.manifest_json));
1790
+ const mapListedVersion = (r) => versionRecord(r, stringListOfJson(r.outbound_json), stringListOfJson(r.calls_json));
1791
+ const versionRecord = (r, outbound, calls) => verticalVersion.parse({
1748
1792
  id: r.id,
1749
1793
  verticalSlug: r.vertical_slug,
1750
1794
  version: r.version,
@@ -1755,8 +1799,8 @@ export class CloudflareScopeHost {
1755
1799
  admission: r.admission,
1756
1800
  admissionNote: r.admission_note,
1757
1801
  origin: r.origin_json ? JSON.parse(r.origin_json) : null,
1758
- outbound: outboundOfManifestJson(r.manifest_json),
1759
- calls: callsOfManifestJson(r.manifest_json),
1802
+ outbound,
1803
+ calls,
1760
1804
  createdAt: r.created_at,
1761
1805
  });
1762
1806
  const mapOrg = (r) => orgSchema.parse({
@@ -1812,7 +1856,13 @@ export class CloudflareScopeHost {
1812
1856
  // reap's `backupRef` (#493) is the first. Kept out of `before` deliberately: it
1813
1857
  // describes what the transition DID, not the state it left.
1814
1858
  afterExtra) => {
1815
- const before = await this.cp.transitionScope(tenantId, scopeId, from, to, action);
1859
+ // The DO answers a refusal as data (#1718): a throw from there would arrive here
1860
+ // flattened, its code gone. The pair check stays in the DO, in the same read as the
1861
+ // write, so a row deleted concurrently is still refused `not_found`.
1862
+ const before = await this.cp.transitionScopeOrRefusal(tenantId, scopeId, from, to, action);
1863
+ if (!before.ok) {
1864
+ throw before.code ? substratError(before.code, before.message) : new Error(before.message);
1865
+ }
1816
1866
  // The audit target carries the scope's vertical (control-plane.md §4.4:
1817
1867
  // "vertical stays null until §4.2 lifecycle actions that name one"). The DO
1818
1868
  // returns it with the previous status, so the trail cannot disagree with
@@ -1869,7 +1919,7 @@ export class CloudflareScopeHost {
1869
1919
  if (delegation) {
1870
1920
  // The gate's lifecycle half still runs (a suspended tenant or scope cannot be
1871
1921
  // switched); its `scopeStub` half is what the delegation replaces.
1872
- await this.cp.validateScopeAccess(tenantId, scopeId);
1922
+ await this.validateScopeAccess(tenantId, scopeId);
1873
1923
  }
1874
1924
  else {
1875
1925
  await this.peerScopeGate(tenantId, scopeId, action);
@@ -1910,9 +1960,15 @@ export class CloudflareScopeHost {
1910
1960
  permissions: outcome.permissions,
1911
1961
  };
1912
1962
  };
1913
- const switchSystem = async (actor, raw, to) => {
1914
- const input = systemSwitch.parse(raw);
1915
- const { tenantId, scopeId } = input.node;
1963
+ /**
1964
+ * Where one scope's schedule switch lands — the ONE rule `switchSystem` and the #1674
1965
+ * re-assert share, so a re-assert moves exactly the switch an operator's OFF would have.
1966
+ * A scope bound to no vertical (#1666 review) has no deployment to delegate to: its store
1967
+ * is the DO here, so the switch moves (or answers `held: false`) here too. Delegating it
1968
+ * would throw "no deployment serving scope" instead. A scope WITH a vertical still
1969
+ * delegates, and still fails loudly when none serves it.
1970
+ */
1971
+ const systemSwitchTarget = async (tenantId, scopeId) => {
1916
1972
  let vertical = null;
1917
1973
  if (!this.cpLess) {
1918
1974
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
@@ -1920,6 +1976,16 @@ export class CloudflareScopeHost {
1920
1976
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
1921
1977
  vertical = rec.vertical;
1922
1978
  }
1979
+ const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
1980
+ const move = (moduleId, to, at) => delegation
1981
+ ? delegation.switch({ tenantId, scopeId, moduleId, to })
1982
+ : this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
1983
+ return { vertical, move };
1984
+ };
1985
+ const switchSystem = async (actor, raw, to) => {
1986
+ const input = systemSwitch.parse(raw);
1987
+ const { tenantId, scopeId } = input.node;
1988
+ const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
1923
1989
  // AUDIT FIRST (#1666 review): the intent row lands before anything moves, and the
1924
1990
  // outcome row after — every attempt, a repeat included. The scope's store and the
1925
1991
  // admin log are separate, so no order makes the pair atomic; this one fails toward
@@ -1930,36 +1996,84 @@ export class CloudflareScopeHost {
1930
1996
  const target = { tenantId, scopeId, vertical };
1931
1997
  const base = { operationId, moduleId: input.moduleId, schedules: to };
1932
1998
  await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
1933
- // A scope bound to no vertical (#1666 review) has no deployment to delegate to: its
1934
- // store is the DO here, so the switch moves (or answers `held: false`) here too.
1935
- // Delegating it would throw "no deployment serving scope" instead. A scope WITH a
1936
- // vertical still delegates, and still fails loudly when none serves it.
1937
- const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
1999
+ // The directory's record (#1674): ON before the scope moves, OFF after it held — see
2000
+ // `recordSystemSwitchedOn` for why that order is the safe one.
2001
+ const at = new Date().toISOString();
2002
+ const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
2003
+ const errorOf = (err) => (err instanceof Error ? err.message : String(err));
2004
+ let prior = null;
2005
+ if (to === 'on') {
2006
+ try {
2007
+ prior = await this.cp.recordSystemSwitchedOn(record);
2008
+ }
2009
+ catch (err) {
2010
+ // Nothing has moved: fail the call here, audited, rather than switch a scope on
2011
+ // whose record still says off (the next reconcile would switch it back off).
2012
+ await this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) }).catch(() => undefined);
2013
+ throw err;
2014
+ }
2015
+ }
2016
+ /**
2017
+ * A record write AFTER the scope moved (review #5): retried once, and a failure of both
2018
+ * is answered rather than swallowed — it lands on the outcome row as `recordError`, and
2019
+ * the call does not report plain success. A lost OFF record is exactly what a wipe then
2020
+ * turns into a module running again.
2021
+ */
2022
+ const recordWrite = async (write) => {
2023
+ try {
2024
+ await write();
2025
+ return null;
2026
+ }
2027
+ catch {
2028
+ try {
2029
+ await write();
2030
+ return null;
2031
+ }
2032
+ catch (err) {
2033
+ return errorOf(err);
2034
+ }
2035
+ }
2036
+ };
1938
2037
  let outcome;
1939
2038
  try {
1940
- outcome = delegation
1941
- ? await delegation.switch({ tenantId, scopeId, moduleId: input.moduleId, to })
1942
- : await this.scopeStub(scopeId).switchSystemSchedules(input.moduleId, scopeId, to, new Date().toISOString());
2039
+ outcome = await move(input.moduleId, to, at);
1943
2040
  }
1944
2041
  catch (err) {
2042
+ const recordError = prior ? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior)) : null;
1945
2043
  // Best effort: the original error is what the caller must see, and the intent row
1946
2044
  // already says an attempt was made.
1947
2045
  await this.recordAdmin(actor, action, target, null, {
1948
2046
  ...base,
1949
2047
  phase: 'failed',
1950
- error: err instanceof Error ? err.message : String(err),
2048
+ error: errorOf(err),
2049
+ ...(recordError ? { recordError } : {}),
1951
2050
  }).catch(() => undefined);
1952
2051
  throw err;
1953
2052
  }
2053
+ // OFF is recorded once the scope held it. A refused ON moved nothing, so its record
2054
+ // write is undone: left `on`, the next reconcile of a wiped scope would leave the
2055
+ // module running.
2056
+ const recordError = to === 'off' && outcome.held
2057
+ ? await recordWrite(() => this.cp.recordSystemSwitchedOff(record))
2058
+ : to === 'on' && !outcome.held && prior
2059
+ ? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior))
2060
+ : null;
1954
2061
  await this.recordAdmin(actor, action, target, null, {
1955
2062
  ...base,
1956
2063
  phase: outcome.held ? 'applied' : 'refused',
1957
2064
  changed: outcome.changed,
1958
2065
  permissions: outcome.permissions,
2066
+ ...(recordError ? { recordError } : {}),
1959
2067
  });
1960
2068
  if (!outcome.held) {
1961
2069
  throw substratError('not_found', `scope ${scopeId} holds no system grant for module '${input.moduleId}' — nothing to switch ${to} ` +
1962
- `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')`);
2070
+ `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
2071
+ (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
2072
+ }
2073
+ if (recordError) {
2074
+ throw substratError('unavailable', `module '${input.moduleId}' is switched ${to} on scope ${scopeId}, but the directory's record of it ` +
2075
+ `could not be written (${recordError}) — repeat the call to record it; a wipe of this scope would ` +
2076
+ `otherwise lose the switch`);
1963
2077
  }
1964
2078
  return {
1965
2079
  operationId,
@@ -2036,23 +2150,65 @@ export class CloudflareScopeHost {
2036
2150
  throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot read its schedule switches`);
2037
2151
  }
2038
2152
  const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
2039
- const states = delegation
2040
- ? await delegation.status({ tenantId, scopeId })
2041
- : systemScheduleEntry.array().parse(await this.scopeStub(scopeId).systemGrantsStatus());
2153
+ // The scope's own position and the directory's record are independent reads.
2154
+ const [states, recordedRows] = await Promise.all([
2155
+ delegation
2156
+ ? delegation.status({ tenantId, scopeId })
2157
+ : this.scopeStub(scopeId).systemGrantsStatus().then((rows) => systemScheduleEntry.array().parse(rows)),
2158
+ this.cp.systemSwitchRecordsOf(tenantId, scopeId),
2159
+ ]);
2042
2160
  const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
2043
2161
  const explanations = offModules.size > 0
2044
2162
  ? await lastSwitchedOff(tenantId, scopeId, offModules)
2045
2163
  : new Map();
2046
- const result = states.map((s) => ({
2164
+ const result = withRecorded(states, new Map(recordedRows)).map((s) => ({
2047
2165
  moduleId: s.moduleId,
2048
2166
  schedules: s.schedules,
2049
2167
  switchedOff: explanations.get(s.moduleId) ?? null,
2168
+ recorded: s.recorded,
2050
2169
  }));
2051
2170
  // K-24: reading the switch's position and any live incident reason is itself
2052
2171
  // access-logged, the same as every other HostAdmin read.
2053
2172
  await this.recordAccess(actor, 'systemGrantsStatus', { tenantId, scopeId }, null, result.length);
2054
2173
  return result;
2055
2174
  };
2175
+ /**
2176
+ * #1674: put the directory's OFF positions back into one scope — see
2177
+ * `HostAdmin.reassertSystemSwitches`. It moves the switch through `systemSwitchTarget`,
2178
+ * so it lands wherever an operator's OFF would have — for a hosted scope, over the
2179
+ * `/internal/system-switch` route every deployment since #1666 answers.
2180
+ */
2181
+ const reassertSystemSwitchesOf = async (actor, node) => {
2182
+ const { tenantId, scopeId } = node;
2183
+ const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
2184
+ const modules = await this.cp.switchedOffModulesOf(tenantId, scopeId);
2185
+ // A hosted scope with no delegation configured (Copilot review): this host's own
2186
+ // namespace is the module-less placeholder, where the switch would answer `held: false`
2187
+ // quietly — a re-assert reported done, and a receipt written, while the deployment
2188
+ // serving the scope keeps its schedules running. Refused loudly instead, as the status
2189
+ // read is, and only when a re-assert is owed: nothing recorded, nothing to refuse.
2190
+ if (modules.length > 0 && !this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
2191
+ throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot re-assert ` +
2192
+ `its switched-off schedules in the deployment serving it`);
2193
+ }
2194
+ const at = new Date().toISOString();
2195
+ const results = [];
2196
+ for (const moduleId of modules) {
2197
+ const outcome = await move(moduleId, 'off', at);
2198
+ if (outcome.changed) {
2199
+ await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
2200
+ operationId: ulid(),
2201
+ moduleId,
2202
+ schedules: 'off',
2203
+ phase: 'applied',
2204
+ changed: true,
2205
+ permissions: outcome.permissions,
2206
+ });
2207
+ }
2208
+ results.push({ moduleId, held: outcome.held, changed: outcome.changed });
2209
+ }
2210
+ return results;
2211
+ };
2056
2212
  /**
2057
2213
  * The peer switch's status read (#1706) — `systemGrantsStatusOf` with the subject
2058
2214
  * swapped, and delegated for the same reason the write is: a hosted scope's
@@ -2179,7 +2335,7 @@ export class CloudflareScopeHost {
2179
2335
  if (grant.node.scopeId) {
2180
2336
  const scope = await this.cp.getScopeRecord(grant.node.tenantId, grant.node.scopeId);
2181
2337
  if (!scope) {
2182
- throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
2338
+ throw substratError('not_found', `unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
2183
2339
  }
2184
2340
  if (scope.vertical !== conn.vertical) {
2185
2341
  throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
@@ -2255,6 +2411,14 @@ export class CloudflareScopeHost {
2255
2411
  // #1674: the switch's status read — same gate, same delegation, and (unlike the
2256
2412
  // deployment it may delegate to) the admin log to explain an `off` entry.
2257
2413
  systemGrantsStatus: systemGrantsStatusOf,
2414
+ // #1674: the directory's record of the switch — the fleet read, and the re-assert a
2415
+ // scope that lost its marker gets after a wipe or a restore.
2416
+ listSystemSwitches: async (actor, filter) => {
2417
+ const rows = await this.cp.listSystemSwitches(filter);
2418
+ await this.recordAccess(actor, 'listSystemSwitches', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
2419
+ return rows.map((r) => systemSwitchRecord.parse(r));
2420
+ },
2421
+ reassertSystemSwitches: reassertSystemSwitchesOf,
2258
2422
  // #1672 — the platform's two capability verbs. Audited AFTER the write, on both, and
2259
2423
  // the failure each leaves is the safe one: a mint whose audit row did not land never
2260
2424
  // returned its secret, so nobody can ever exchange it; a revoke whose row did not land
@@ -2264,7 +2428,7 @@ export class CloudflareScopeHost {
2264
2428
  // a bare message, and the caller would lose `validation_failed`. One function, so the
2265
2429
  // two sides cannot disagree about what a valid mint is.
2266
2430
  checkBecomeInput(input, new Date().toISOString());
2267
- await this.cp.validateScopeAccess(tenantId, scopeId);
2431
+ await this.validateScopeAccess(tenantId, scopeId);
2268
2432
  const { stub, vertical } = await this.capabilityScopeStub(tenantId, scopeId, 'mintCapability');
2269
2433
  const minted = await stub.mintBecomeCapability(input, actor);
2270
2434
  await this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId, vertical }, null, {
@@ -2303,7 +2467,7 @@ export class CloudflareScopeHost {
2303
2467
  const parsed = bindHostnameInput.parse(input);
2304
2468
  const scope = await this.cp.getScopeRecord(parsed.tenantId, parsed.scopeId);
2305
2469
  if (!scope) {
2306
- throw new Error(`unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
2470
+ throw substratError('not_found', `unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
2307
2471
  }
2308
2472
  const existing = await this.cp.readHostname(parsed.hostname);
2309
2473
  // The holder's own status decides whether the name is reclaimable. Read it from
@@ -2477,7 +2641,7 @@ export class CloudflareScopeHost {
2477
2641
  listVersions: async (actor, verticalSlug, page) => {
2478
2642
  const rows = await this.cp.listVersions(verticalSlug, page);
2479
2643
  await this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
2480
- return rows.map(mapVersion);
2644
+ return rows.map(mapListedVersion);
2481
2645
  },
2482
2646
  getVersion: async (actor, versionId, verticalSlug) => {
2483
2647
  const row = await this.cp.readVersion(versionId);
@@ -2571,6 +2735,19 @@ export class CloudflareScopeHost {
2571
2735
  await this.cp.setAdmission(versionId, 'admitted', null);
2572
2736
  await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' });
2573
2737
  },
2738
+ promotionImpact: async (actor, verticalSlug, channel, versionId) => {
2739
+ const incoming = await this.cp.readVersion(versionId);
2740
+ if (!incoming || incoming.vertical_slug !== verticalSlug) {
2741
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
2742
+ }
2743
+ const current = await this.cp.readChannel(verticalSlug, channel);
2744
+ const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
2745
+ const breaks = outgoing
2746
+ ? await this.exportBreaksBetween(actor, verticalSlug, outgoing.manifest_json, incoming.manifest_json)
2747
+ : [];
2748
+ await this.recordAccess(actor, 'promotionImpact', { tenantId: null }, { verticalSlug, channel, versionId }, breaks.length);
2749
+ return breaks;
2750
+ },
2574
2751
  rejectVersion: async (actor, versionId, note) => {
2575
2752
  const v = await this.cp.readVersion(versionId);
2576
2753
  if (!v)
@@ -2607,6 +2784,12 @@ export class CloudflareScopeHost {
2607
2784
  throw new Error(`promotion changes migrations (${outgoing.migration_digest} → ` +
2608
2785
  `${incoming.migration_digest}) — acknowledge it explicitly to promote`);
2609
2786
  }
2787
+ // #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
2788
+ if (!ack.exportBreak) {
2789
+ const breaks = await this.exportBreaksBetween(actor, verticalSlug, outgoing.manifest_json, incoming.manifest_json);
2790
+ if (breaks.length > 0)
2791
+ throw substratError('precondition_failed', exportBreakRefusal(breaks));
2792
+ }
2610
2793
  }
2611
2794
  const promotedAt = new Date().toISOString();
2612
2795
  await this.cp.setChannel(verticalSlug, channel, versionId, promotedAt);
@@ -2694,7 +2877,7 @@ export class CloudflareScopeHost {
2694
2877
  throw substratError('not_found', `unknown version ${versionId}`);
2695
2878
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2696
2879
  if (!scope)
2697
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2880
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2698
2881
  // The refusal the registry exists for — but scoped to a SERVING bind. Admission
2699
2882
  // gates code reaching an install; a PREVIEW fork is the builder's own tenant's data
2700
2883
  // at a non-canonical URL, serving no install, so it may run pending PR code — the
@@ -2728,7 +2911,7 @@ export class CloudflareScopeHost {
2728
2911
  markScopeProvisioned: async (actor, tenantId, scopeId, versionId) => {
2729
2912
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2730
2913
  if (!scope)
2731
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2914
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2732
2915
  await this.cp.markScopeProvisioned(scopeId, versionId);
2733
2916
  await this.recordAdmin(actor, 'markScopeProvisioned', { tenantId, scopeId }, null, {
2734
2917
  versionId,
@@ -2774,21 +2957,21 @@ export class CloudflareScopeHost {
2774
2957
  setScopeServingRef: async (actor, tenantId, scopeId, servingRef) => {
2775
2958
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2776
2959
  if (!scope)
2777
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2960
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2778
2961
  await this.cp.setScopeServingRef(scopeId, servingRef);
2779
2962
  await this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref ?? null }, { servingRef });
2780
2963
  },
2781
2964
  setScopeExpiresAt: async (actor, tenantId, scopeId, expiresAt) => {
2782
2965
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2783
2966
  if (!scope)
2784
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2967
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2785
2968
  await this.cp.setScopeExpiresAt(scopeId, expiresAt);
2786
2969
  await this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at ?? null }, { expiresAt });
2787
2970
  },
2788
2971
  scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
2789
2972
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2790
2973
  if (!scope)
2791
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2974
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2792
2975
  const applied = await this.scopeStub(scopeId).appliedMigrations();
2793
2976
  await this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, applied.length);
2794
2977
  return applied;
@@ -2796,7 +2979,7 @@ export class CloudflareScopeHost {
2796
2979
  scopeMigrationBookmarks: async (actor, tenantId, scopeId) => {
2797
2980
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2798
2981
  if (!scope)
2799
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2982
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2800
2983
  const bookmarks = await this.scopeStub(scopeId).migrationBookmarks();
2801
2984
  await this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, bookmarks.length);
2802
2985
  return bookmarks;
@@ -2811,7 +2994,7 @@ export class CloudflareScopeHost {
2811
2994
  rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
2812
2995
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2813
2996
  if (!scope)
2814
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2997
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2815
2998
  // Audit FIRST: a destructive rewind that fails halfway must still be on the
2816
2999
  // record — the entry names the intent; the DO's refusals name the outcome.
2817
3000
  await this.recordAdmin(actor, 'rewindScope', { tenantId, scopeId }, null, {
@@ -2995,6 +3178,7 @@ export class CloudflareScopeHost {
2995
3178
  await this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, state.cursors.length);
2996
3179
  return state;
2997
3180
  },
3181
+ moveImportCursor: async (actor, tenantId, scopeId, raw) => this.moveImportCursorAt(actor, tenantId, scopeId, raw),
2998
3182
  readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
2999
3183
  const record = await this.scopeRecordForRead(tenantId, scopeId);
3000
3184
  const bounded = Math.min(Math.max(limit ?? 200, 1), 1000);
@@ -3264,7 +3448,7 @@ export class CloudflareScopeHost {
3264
3448
  // no-op when the bytes lived remotely.
3265
3449
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
3266
3450
  if (!rec)
3267
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
3451
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
3268
3452
  if (rec.status !== 'archived') {
3269
3453
  throw new Error(`scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
3270
3454
  }
@@ -3906,9 +4090,18 @@ export class CloudflareScopeHost {
3906
4090
  }
3907
4091
  // -- helpers --------------------------------------------------------------
3908
4092
  /**
3909
- * Record a staff read (K-24). `params` is a bounded summary, capped so one query
3910
- * cannot write an unbounded row.
4093
+ * The getScope gate (control-plane.md §4.1/§4.2) — the pair check, then the tenant's and
4094
+ * the scope's lifecycle — thrown on THIS side of the RPC (#1718). The ControlPlaneDO
4095
+ * answers its refusal as data, because an error thrown there arrives here flattened, with
4096
+ * no code; a record crosses intact. A CP-less host's null control plane answers nothing,
4097
+ * so the gate passes: the router already made it from the shared directory.
3911
4098
  */
4099
+ async validateScopeAccess(tenantId, scopeId) {
4100
+ const refusal = await this.cp.scopeAccessRefusal(tenantId, scopeId);
4101
+ if (!refusal)
4102
+ return;
4103
+ throw refusal.code ? substratError(refusal.code, refusal.message) : new Error(refusal.message);
4104
+ }
3912
4105
  /**
3913
4106
  * K-3's cross-check on its own: the (tenant, scope) pair must exist and agree before a
3914
4107
  * subject-key operation touches anything. Without it a caller could reach another
@@ -3917,7 +4110,7 @@ export class CloudflareScopeHost {
3917
4110
  async assertScope(tenantId, scopeId) {
3918
4111
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
3919
4112
  if (!rec)
3920
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
4113
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3921
4114
  }
3922
4115
  /**
3923
4116
  * The same K-3 cross-check, for a read that then opens the scope's STORAGE — every
@@ -3932,7 +4125,7 @@ export class CloudflareScopeHost {
3932
4125
  async scopeRecordForRead(tenantId, scopeId) {
3933
4126
  const row = await this.cp.getScopeRecord(tenantId, scopeId);
3934
4127
  if (!row)
3935
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
4128
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3936
4129
  if (row.status === 'reaped') {
3937
4130
  throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
3938
4131
  }
@@ -3950,6 +4143,10 @@ export class CloudflareScopeHost {
3950
4143
  tombstone: (subjectId, at) => this.cp.tombstoneSubjectKey({ scopeId, subjectId, tenantId, at }),
3951
4144
  });
3952
4145
  }
4146
+ /**
4147
+ * Record a staff read (K-24). `params` is a bounded summary, capped so one query
4148
+ * cannot write an unbounded row.
4149
+ */
3953
4150
  async recordAccess(actor, method, target, params, resultCount) {
3954
4151
  await this.cp.recordAccess({
3955
4152
  id: ulid(),
@@ -3985,7 +4182,7 @@ export class CloudflareScopeHost {
3985
4182
  * directory's view stays on `HostAdmin.listConnectionGrants`.
3986
4183
  */
3987
4184
  async connectionGrantsInScope(tenantId, scopeId) {
3988
- await this.cp.validateScopeAccess(tenantId, scopeId);
4185
+ await this.validateScopeAccess(tenantId, scopeId);
3989
4186
  await this.migrateAndRecord(scopeId);
3990
4187
  const now = new Date().toISOString();
3991
4188
  // Both stores again, but the CF split is not the pure adapter's. The DO holds the
@@ -4161,7 +4358,7 @@ export class CloudflareScopeHost {
4161
4358
  * count to the directory — so a scope whose first contact after a deploy is a
4162
4359
  * subscription does not go dark in the migration fleet view.
4163
4360
  */
4164
- await this.cp.validateScopeAccess(tenantId, scopeId);
4361
+ await this.validateScopeAccess(tenantId, scopeId);
4165
4362
  await this.migrateAndRecord(scopeId);
4166
4363
  // Asserted, not carried through from the client: the principal is the
4167
4364
  // vertical's own resolution of its session, and the tenant and scope are the
@@ -4576,6 +4773,164 @@ export class CloudflareScopeHost {
4576
4773
  async peerSwitchLocal(scopeId, vertical, to) {
4577
4774
  return peerSwitchOutcome.parse(await this.scopeStub(scopeId).switchPeer(verticalSlug.parse(vertical), scopeId, to, new Date().toISOString()));
4578
4775
  }
4776
+ // -- the cross-vertical far ends (#1705 PR 2) --------------------------------
4777
+ // The shared control plane runs the cross-vertical phase for every hosted edge and reaches the
4778
+ // scopes over `/internal/exported-events`, `/internal/import-state` and `/internal/import-events`,
4779
+ // which land here. The platform resolved the pair and the tenant; what runs HERE is what only
4780
+ // this deployment can decide: what its own code exports and to whom, and what its own code
4781
+ // imports and runs. Each verb first proves the scope is one this deployment serves
4782
+ // (`assertServesLocally`), because a CP-less host has no directory and an unprovisioned DO
4783
+ // answers every read with a plausible empty result.
4784
+ /** The producer's release after a watermark (#1705), for a scope served HERE. */
4785
+ async exportedEventsLocal(tenantId, scopeId, raw) {
4786
+ const input = exportReadInput.parse(raw);
4787
+ await this.assertServesLocally(tenantId, scopeId, 'readExportedEvents');
4788
+ return exportedBatch.parse(await this.scopeStub(scopeId).exportedEventsRead(input, tenantId, scopeId));
4789
+ }
4790
+ /**
4791
+ * The consumer's imports and watermarks (#1705), for a scope served HERE. The served-here
4792
+ * check comes FIRST, before the "this deployment imports nothing" answer. That answer is a
4793
+ * fact about this code, but given for a scope this deployment does not serve, it would tell the
4794
+ * platform the scope imports nothing, when the truth is that the platform asked the wrong
4795
+ * deployment. The platform reads the former as a disagreement with its registry, and must
4796
+ * hear the latter as a refusal.
4797
+ */
4798
+ async importStateLocal(tenantId, scopeId) {
4799
+ await this.assertServesLocally(tenantId, scopeId, 'importState');
4800
+ if (this.crossVertical.consumes().length === 0)
4801
+ return { consumes: [], cursors: [] };
4802
+ return importState.parse(await this.scopeStub(scopeId).importStateRead());
4803
+ }
4804
+ /** Apply a producer's batch (#1705) to a scope served HERE, through the peer door's gate. */
4805
+ async importEventsLocal(tenantId, scopeId, batch) {
4806
+ await this.assertServesLocally(tenantId, scopeId, 'deliverToPeer');
4807
+ return this.deliverToPeer(tenantId, scopeId, batch);
4808
+ }
4809
+ /**
4810
+ * The replay lever's far end (#1705 PR 3), for a consumer scope served HERE. The platform
4811
+ * resolved the producer and wrote the intent row. What runs here is the move itself, in this
4812
+ * deployment's own store, under the platform's `replayId`. Nothing is audited here, as for the
4813
+ * switches: the admin rows are the platform's.
4814
+ */
4815
+ async importCursorLocal(tenantId, scopeId, raw) {
4816
+ const input = importCursorMoveAt.parse(raw);
4817
+ await this.assertServesLocally(tenantId, scopeId, 'moveImportCursor');
4818
+ return this.moveInScope(scopeId, input);
4819
+ }
4820
+ /** The move in this host's own scope DO, on the real clock (host code may read it). */
4821
+ async moveInScope(scopeId, at) {
4822
+ return importCursorMoved.parse(await this.scopeStub(scopeId).importCursorMove({ ...at, now: Date.now() }));
4823
+ }
4824
+ /**
4825
+ * `HostAdmin.moveImportCursor` (#1705 PR 3): the peer switch's shape. Resolve, audit the
4826
+ * intent, move where the scope's storage is (the delegation for a hosted scope, this host's
4827
+ * own DO otherwise), and audit the outcome. Every attempt leaves a row, and a failed one
4828
+ * says why.
4829
+ */
4830
+ async moveImportCursorAt(actor, tenantId, scopeId, raw) {
4831
+ const move = importCursorMove.parse(raw);
4832
+ if (this.cpLess) {
4833
+ throw substratError('unavailable', 'moveImportCursor needs the directory to resolve the producer — the platform moves a watermark, ' +
4834
+ 'and reaches this deployment through /internal/import-cursor');
4835
+ }
4836
+ const rec = await this.cp.getScopeRecord(tenantId, scopeId);
4837
+ if (!rec)
4838
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4839
+ const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
4840
+ // Where the write lands is the peer switch's rule: a scope bound to a vertical is served by
4841
+ // that vertical's deployment, and its watermark lives there. Without a delegation that
4842
+ // refusal stands (`assertServedHere`) rather than a move in the placeholder namespace.
4843
+ const delegation = rec.vertical !== null ? this.importCursorDelegation : undefined;
4844
+ if (!delegation)
4845
+ this.assertServedHere(rec, scopeId, 'moveImportCursor');
4846
+ await this.validateScopeAccess(tenantId, scopeId);
4847
+ const replayId = ulid();
4848
+ const target = { tenantId, scopeId, vertical: rec.vertical };
4849
+ const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
4850
+ await this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
4851
+ const at = { move, source: source, replayId };
4852
+ let moved;
4853
+ try {
4854
+ moved = delegation
4855
+ ? importCursorMoved.parse(await delegation.move({ tenantId, scopeId, at }))
4856
+ : await this.moveInScope(scopeId, at);
4857
+ }
4858
+ catch (err) {
4859
+ await this.recordAdmin(actor, 'moveImportCursor', target, null, {
4860
+ ...base,
4861
+ phase: 'failed',
4862
+ error: err instanceof Error ? err.message : String(err),
4863
+ }).catch(() => undefined);
4864
+ throw err;
4865
+ }
4866
+ await this.recordAdmin(actor, 'moveImportCursor', target, null, {
4867
+ ...base,
4868
+ phase: 'applied',
4869
+ previous: moved.previous,
4870
+ cursor: moved.cursor,
4871
+ archived: moved.archived,
4872
+ });
4873
+ return moved;
4874
+ }
4875
+ /** #1705 PR 3: the promote gate's question, over two stored manifests (`exportBreaksOf`). */
4876
+ exportBreaksBetween(actor, producer, outgoingManifest, incomingManifest) {
4877
+ return exportBreaksOf({
4878
+ admin: this.admin,
4879
+ actor,
4880
+ producer,
4881
+ outgoing: exportsOfManifestJson(outgoingManifest),
4882
+ incoming: exportsOfManifestJson(incomingManifest),
4883
+ readImports: (slug, versionId) => this.versionImports(slug, versionId),
4884
+ });
4885
+ }
4886
+ /**
4887
+ * The served-here gate (#1705 PR 2), from whichever source of truth this host has.
4888
+ *
4889
+ * With a directory, the directory decides: the record must exist for this (tenant, scope), and
4890
+ * the shared control plane refuses a scope bound to a vertical (`assertServedHere`). Role rows
4891
+ * are not consulted, because a directory-backed host provisions without projecting them.
4892
+ *
4893
+ * CP-less, which is every pushed vertical, there is no directory. The scope must have been
4894
+ * provisioned in THIS deployment's namespace, for THIS tenant (`ScopeDO.servesTenant`).
4895
+ *
4896
+ * A scope this host does not hold is refused `conflict`, not `not_found`: over `/internal` a
4897
+ * 404 means "this deployment predates the route", and the platform reads it as exactly that.
4898
+ * One exception: the SHARED control plane (a directory host with delegations) refuses a scope
4899
+ * bound to a vertical `unavailable`, through `assertServedHere`, the same answer its peer door
4900
+ * gives. It never mounts these routes, so no `/internal` caller meets that answer.
4901
+ */
4902
+ async assertServesLocally(tenantId, scopeId, verb) {
4903
+ if (!this.cpLess) {
4904
+ const record = await this.cp.getScopeRecord(tenantId, scopeId);
4905
+ if (!record) {
4906
+ throw substratError('conflict', `${verb} cannot answer for scope ${scopeId}: the directory has no such scope for tenant ${tenantId}`);
4907
+ }
4908
+ this.assertServedHere(record, scopeId, verb);
4909
+ return;
4910
+ }
4911
+ if (!(await this.scopeStub(scopeId).servesTenant(tenantId))) {
4912
+ throw substratError('conflict', `${verb} cannot answer for scope ${scopeId}: this deployment holds no scope provisioned for tenant ` +
4913
+ `${tenantId} under that id — the platform resolved the scope to a deployment that does not serve it`);
4914
+ }
4915
+ }
4916
+ /**
4917
+ * What one pushed version imports from other verticals (#1705 PR 2), from its stored manifest:
4918
+ * the control plane's cross-vertical narrowing reads this to decide which scopes it calls.
4919
+ *
4920
+ * Deliberately NOT through `admin.versionManifest`, which writes an access-log row per read.
4921
+ * The narrowing asks once per distinct running version on every pass and every kick. Audited,
4922
+ * the log would grow with fleet × tick rate + request rate, for a read of the platform's own
4923
+ * code metadata (a version's declared edges, which a push put there), not of a tenant's data.
4924
+ * Throws `not_found` for a version the registry does not know under that vertical, which the
4925
+ * narrowing reports and treats as "cannot say" rather than "imports nothing".
4926
+ */
4927
+ async versionImports(verticalSlug, versionId) {
4928
+ const v = await this.cp.readVersion(versionId);
4929
+ if (!v || v.vertical_slug !== verticalSlug) {
4930
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
4931
+ }
4932
+ return importsOfManifestJson(v.manifest_json);
4933
+ }
4579
4934
  }
4580
4935
  /** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
4581
4936
  async function sha256Hex(body) {