@substrat-run/adapter-cloudflare 0.120.0 → 0.122.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, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, instant, meterReading, subjectRef, createConnectionInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, substratError, redrainEventsInput, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, verticalSlug, } from '@substrat-run/contracts';
2
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, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, } 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({
@@ -1807,12 +1851,38 @@ export class CloudflareScopeHost {
1807
1851
  archivedAt: r.archived_at ?? null,
1808
1852
  createdAt: r.created_at,
1809
1853
  });
1854
+ // The (version, scope) pair a bind and its impact read both start from, and the refusals
1855
+ // that come before any export-break question, in the order the bind makes them (#1756).
1856
+ const bindTarget = async (tenantId, scopeId, versionId) => {
1857
+ const v = await this.cp.readVersion(versionId);
1858
+ if (!v)
1859
+ throw substratError('not_found', `unknown version ${versionId}`);
1860
+ const scope = await this.cp.getScopeRecord(tenantId, scopeId);
1861
+ if (!scope)
1862
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1863
+ // The refusal the registry exists for — but scoped to a SERVING bind. Admission
1864
+ // gates code reaching an install; a PREVIEW fork is the builder's own tenant's data
1865
+ // at a non-canonical URL, serving no install, so it may run pending PR code — the
1866
+ // same own-tenant blast radius that lets a private vertical self-admit. This is what
1867
+ // lets a LISTED vertical's builder still preview their own new code (marketplace-publish.md
1868
+ // §2; issue #509 ask (d)). Every other scope kind keeps the refusal.
1869
+ if (v.admission !== 'admitted' && scope.kind !== 'preview') {
1870
+ throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
1871
+ }
1872
+ return { v, scope };
1873
+ };
1810
1874
  const transitionScope = async (actor, action, tenantId, scopeId, from, to,
1811
1875
  // Extra `after` fields for transitions that carry more than the new status —
1812
1876
  // reap's `backupRef` (#493) is the first. Kept out of `before` deliberately: it
1813
1877
  // describes what the transition DID, not the state it left.
1814
1878
  afterExtra) => {
1815
- const before = await this.cp.transitionScope(tenantId, scopeId, from, to, action);
1879
+ // The DO answers a refusal as data (#1718): a throw from there would arrive here
1880
+ // flattened, its code gone. The pair check stays in the DO, in the same read as the
1881
+ // write, so a row deleted concurrently is still refused `not_found`.
1882
+ const before = await this.cp.transitionScopeOrRefusal(tenantId, scopeId, from, to, action);
1883
+ if (!before.ok) {
1884
+ throw before.code ? substratError(before.code, before.message) : new Error(before.message);
1885
+ }
1816
1886
  // The audit target carries the scope's vertical (control-plane.md §4.4:
1817
1887
  // "vertical stays null until §4.2 lifecycle actions that name one"). The DO
1818
1888
  // returns it with the previous status, so the trail cannot disagree with
@@ -1869,7 +1939,7 @@ export class CloudflareScopeHost {
1869
1939
  if (delegation) {
1870
1940
  // The gate's lifecycle half still runs (a suspended tenant or scope cannot be
1871
1941
  // switched); its `scopeStub` half is what the delegation replaces.
1872
- await this.cp.validateScopeAccess(tenantId, scopeId);
1942
+ await this.validateScopeAccess(tenantId, scopeId);
1873
1943
  }
1874
1944
  else {
1875
1945
  await this.peerScopeGate(tenantId, scopeId, action);
@@ -1910,9 +1980,15 @@ export class CloudflareScopeHost {
1910
1980
  permissions: outcome.permissions,
1911
1981
  };
1912
1982
  };
1913
- const switchSystem = async (actor, raw, to) => {
1914
- const input = systemSwitch.parse(raw);
1915
- const { tenantId, scopeId } = input.node;
1983
+ /**
1984
+ * Where one scope's schedule switch lands — the ONE rule `switchSystem` and the #1674
1985
+ * re-assert share, so a re-assert moves exactly the switch an operator's OFF would have.
1986
+ * A scope bound to no vertical (#1666 review) has no deployment to delegate to: its store
1987
+ * is the DO here, so the switch moves (or answers `held: false`) here too. Delegating it
1988
+ * would throw "no deployment serving scope" instead. A scope WITH a vertical still
1989
+ * delegates, and still fails loudly when none serves it.
1990
+ */
1991
+ const systemSwitchTarget = async (tenantId, scopeId) => {
1916
1992
  let vertical = null;
1917
1993
  if (!this.cpLess) {
1918
1994
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
@@ -1920,6 +1996,16 @@ export class CloudflareScopeHost {
1920
1996
  throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
1921
1997
  vertical = rec.vertical;
1922
1998
  }
1999
+ const delegation = this.cpLess || vertical !== null ? this.systemSwitchDelegation : undefined;
2000
+ const move = (moduleId, to, at) => delegation
2001
+ ? delegation.switch({ tenantId, scopeId, moduleId, to })
2002
+ : this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, at);
2003
+ return { vertical, move };
2004
+ };
2005
+ const switchSystem = async (actor, raw, to) => {
2006
+ const input = systemSwitch.parse(raw);
2007
+ const { tenantId, scopeId } = input.node;
2008
+ const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
1923
2009
  // AUDIT FIRST (#1666 review): the intent row lands before anything moves, and the
1924
2010
  // outcome row after — every attempt, a repeat included. The scope's store and the
1925
2011
  // admin log are separate, so no order makes the pair atomic; this one fails toward
@@ -1930,36 +2016,84 @@ export class CloudflareScopeHost {
1930
2016
  const target = { tenantId, scopeId, vertical };
1931
2017
  const base = { operationId, moduleId: input.moduleId, schedules: to };
1932
2018
  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;
2019
+ // The directory's record (#1674): ON before the scope moves, OFF after it held — see
2020
+ // `recordSystemSwitchedOn` for why that order is the safe one.
2021
+ const at = new Date().toISOString();
2022
+ const record = { tenantId, scopeId, moduleId: input.moduleId, actor, reason: input.reason, operationId, at };
2023
+ const errorOf = (err) => (err instanceof Error ? err.message : String(err));
2024
+ let prior = null;
2025
+ if (to === 'on') {
2026
+ try {
2027
+ prior = await this.cp.recordSystemSwitchedOn(record);
2028
+ }
2029
+ catch (err) {
2030
+ // Nothing has moved: fail the call here, audited, rather than switch a scope on
2031
+ // whose record still says off (the next reconcile would switch it back off).
2032
+ await this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) }).catch(() => undefined);
2033
+ throw err;
2034
+ }
2035
+ }
2036
+ /**
2037
+ * A record write AFTER the scope moved (review #5): retried once, and a failure of both
2038
+ * is answered rather than swallowed — it lands on the outcome row as `recordError`, and
2039
+ * the call does not report plain success. A lost OFF record is exactly what a wipe then
2040
+ * turns into a module running again.
2041
+ */
2042
+ const recordWrite = async (write) => {
2043
+ try {
2044
+ await write();
2045
+ return null;
2046
+ }
2047
+ catch {
2048
+ try {
2049
+ await write();
2050
+ return null;
2051
+ }
2052
+ catch (err) {
2053
+ return errorOf(err);
2054
+ }
2055
+ }
2056
+ };
1938
2057
  let outcome;
1939
2058
  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());
2059
+ outcome = await move(input.moduleId, to, at);
1943
2060
  }
1944
2061
  catch (err) {
2062
+ const recordError = prior ? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior)) : null;
1945
2063
  // Best effort: the original error is what the caller must see, and the intent row
1946
2064
  // already says an attempt was made.
1947
2065
  await this.recordAdmin(actor, action, target, null, {
1948
2066
  ...base,
1949
2067
  phase: 'failed',
1950
- error: err instanceof Error ? err.message : String(err),
2068
+ error: errorOf(err),
2069
+ ...(recordError ? { recordError } : {}),
1951
2070
  }).catch(() => undefined);
1952
2071
  throw err;
1953
2072
  }
2073
+ // OFF is recorded once the scope held it. A refused ON moved nothing, so its record
2074
+ // write is undone: left `on`, the next reconcile of a wiped scope would leave the
2075
+ // module running.
2076
+ const recordError = to === 'off' && outcome.held
2077
+ ? await recordWrite(() => this.cp.recordSystemSwitchedOff(record))
2078
+ : to === 'on' && !outcome.held && prior
2079
+ ? await recordWrite(() => this.cp.restoreSystemSwitchRecord(record, prior))
2080
+ : null;
1954
2081
  await this.recordAdmin(actor, action, target, null, {
1955
2082
  ...base,
1956
2083
  phase: outcome.held ? 'applied' : 'refused',
1957
2084
  changed: outcome.changed,
1958
2085
  permissions: outcome.permissions,
2086
+ ...(recordError ? { recordError } : {}),
1959
2087
  });
1960
2088
  if (!outcome.held) {
1961
2089
  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')`);
2090
+ `(check the module id: it is the module's manifest id, e.g. '@substrat-run/engine-absence')` +
2091
+ (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
2092
+ }
2093
+ if (recordError) {
2094
+ throw substratError('unavailable', `module '${input.moduleId}' is switched ${to} on scope ${scopeId}, but the directory's record of it ` +
2095
+ `could not be written (${recordError}) — repeat the call to record it; a wipe of this scope would ` +
2096
+ `otherwise lose the switch`);
1963
2097
  }
1964
2098
  return {
1965
2099
  operationId,
@@ -2036,23 +2170,65 @@ export class CloudflareScopeHost {
2036
2170
  throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot read its schedule switches`);
2037
2171
  }
2038
2172
  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());
2173
+ // The scope's own position and the directory's record are independent reads.
2174
+ const [states, recordedRows] = await Promise.all([
2175
+ delegation
2176
+ ? delegation.status({ tenantId, scopeId })
2177
+ : this.scopeStub(scopeId).systemGrantsStatus().then((rows) => systemScheduleEntry.array().parse(rows)),
2178
+ this.cp.systemSwitchRecordsOf(tenantId, scopeId),
2179
+ ]);
2042
2180
  const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
2043
2181
  const explanations = offModules.size > 0
2044
2182
  ? await lastSwitchedOff(tenantId, scopeId, offModules)
2045
2183
  : new Map();
2046
- const result = states.map((s) => ({
2184
+ const result = withRecorded(states, new Map(recordedRows)).map((s) => ({
2047
2185
  moduleId: s.moduleId,
2048
2186
  schedules: s.schedules,
2049
2187
  switchedOff: explanations.get(s.moduleId) ?? null,
2188
+ recorded: s.recorded,
2050
2189
  }));
2051
2190
  // K-24: reading the switch's position and any live incident reason is itself
2052
2191
  // access-logged, the same as every other HostAdmin read.
2053
2192
  await this.recordAccess(actor, 'systemGrantsStatus', { tenantId, scopeId }, null, result.length);
2054
2193
  return result;
2055
2194
  };
2195
+ /**
2196
+ * #1674: put the directory's OFF positions back into one scope — see
2197
+ * `HostAdmin.reassertSystemSwitches`. It moves the switch through `systemSwitchTarget`,
2198
+ * so it lands wherever an operator's OFF would have — for a hosted scope, over the
2199
+ * `/internal/system-switch` route every deployment since #1666 answers.
2200
+ */
2201
+ const reassertSystemSwitchesOf = async (actor, node) => {
2202
+ const { tenantId, scopeId } = node;
2203
+ const { vertical, move } = await systemSwitchTarget(tenantId, scopeId);
2204
+ const modules = await this.cp.switchedOffModulesOf(tenantId, scopeId);
2205
+ // A hosted scope with no delegation configured (Copilot review): this host's own
2206
+ // namespace is the module-less placeholder, where the switch would answer `held: false`
2207
+ // quietly — a re-assert reported done, and a receipt written, while the deployment
2208
+ // serving the scope keeps its schedules running. Refused loudly instead, as the status
2209
+ // read is, and only when a re-assert is owed: nothing recorded, nothing to refuse.
2210
+ if (modules.length > 0 && !this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
2211
+ throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot re-assert ` +
2212
+ `its switched-off schedules in the deployment serving it`);
2213
+ }
2214
+ const at = new Date().toISOString();
2215
+ const results = [];
2216
+ for (const moduleId of modules) {
2217
+ const outcome = await move(moduleId, 'off', at);
2218
+ if (outcome.changed) {
2219
+ await this.recordAdmin(actor, 'reassertSystemSwitch', { tenantId, scopeId, vertical }, null, {
2220
+ operationId: ulid(),
2221
+ moduleId,
2222
+ schedules: 'off',
2223
+ phase: 'applied',
2224
+ changed: true,
2225
+ permissions: outcome.permissions,
2226
+ });
2227
+ }
2228
+ results.push({ moduleId, held: outcome.held, changed: outcome.changed });
2229
+ }
2230
+ return results;
2231
+ };
2056
2232
  /**
2057
2233
  * The peer switch's status read (#1706) — `systemGrantsStatusOf` with the subject
2058
2234
  * swapped, and delegated for the same reason the write is: a hosted scope's
@@ -2179,7 +2355,7 @@ export class CloudflareScopeHost {
2179
2355
  if (grant.node.scopeId) {
2180
2356
  const scope = await this.cp.getScopeRecord(grant.node.tenantId, grant.node.scopeId);
2181
2357
  if (!scope) {
2182
- throw new Error(`unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
2358
+ throw substratError('not_found', `unknown scope ${grant.node.scopeId} in tenant ${grant.node.tenantId}`);
2183
2359
  }
2184
2360
  if (scope.vertical !== conn.vertical) {
2185
2361
  throw new Error(`connection ${grant.connectionId} is for vertical '${conn.vertical}' and scope ` +
@@ -2255,6 +2431,14 @@ export class CloudflareScopeHost {
2255
2431
  // #1674: the switch's status read — same gate, same delegation, and (unlike the
2256
2432
  // deployment it may delegate to) the admin log to explain an `off` entry.
2257
2433
  systemGrantsStatus: systemGrantsStatusOf,
2434
+ // #1674: the directory's record of the switch — the fleet read, and the re-assert a
2435
+ // scope that lost its marker gets after a wipe or a restore.
2436
+ listSystemSwitches: async (actor, filter) => {
2437
+ const rows = await this.cp.listSystemSwitches(filter);
2438
+ await this.recordAccess(actor, 'listSystemSwitches', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
2439
+ return rows.map((r) => systemSwitchRecord.parse(r));
2440
+ },
2441
+ reassertSystemSwitches: reassertSystemSwitchesOf,
2258
2442
  // #1672 — the platform's two capability verbs. Audited AFTER the write, on both, and
2259
2443
  // the failure each leaves is the safe one: a mint whose audit row did not land never
2260
2444
  // returned its secret, so nobody can ever exchange it; a revoke whose row did not land
@@ -2264,7 +2448,7 @@ export class CloudflareScopeHost {
2264
2448
  // a bare message, and the caller would lose `validation_failed`. One function, so the
2265
2449
  // two sides cannot disagree about what a valid mint is.
2266
2450
  checkBecomeInput(input, new Date().toISOString());
2267
- await this.cp.validateScopeAccess(tenantId, scopeId);
2451
+ await this.validateScopeAccess(tenantId, scopeId);
2268
2452
  const { stub, vertical } = await this.capabilityScopeStub(tenantId, scopeId, 'mintCapability');
2269
2453
  const minted = await stub.mintBecomeCapability(input, actor);
2270
2454
  await this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId, vertical }, null, {
@@ -2303,7 +2487,7 @@ export class CloudflareScopeHost {
2303
2487
  const parsed = bindHostnameInput.parse(input);
2304
2488
  const scope = await this.cp.getScopeRecord(parsed.tenantId, parsed.scopeId);
2305
2489
  if (!scope) {
2306
- throw new Error(`unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
2490
+ throw substratError('not_found', `unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
2307
2491
  }
2308
2492
  const existing = await this.cp.readHostname(parsed.hostname);
2309
2493
  // The holder's own status decides whether the name is reclaimable. Read it from
@@ -2460,9 +2644,13 @@ export class CloudflareScopeHost {
2460
2644
  // The manifest is retained for the serving upload (#286), not audited — a whole
2461
2645
  // manifest per publish would drown the admin log in bundle metadata.
2462
2646
  const { manifestJson, origin, ...audited } = parsed;
2647
+ // The SQL migrations are stored apart from the manifest (#1764), so no read of the
2648
+ // version but the promote review's carries them.
2649
+ const split = splitManifestMigrations(manifestJson ?? null, 'push');
2463
2650
  await this.cp.insertVersion({
2464
2651
  ...audited,
2465
- manifestJson: manifestJson ?? null,
2652
+ manifestJson: split.manifestJson,
2653
+ migrations: split.migrations,
2466
2654
  originJson: origin ? JSON.stringify(origin) : null,
2467
2655
  admission: selfAdmits ? 'admitted' : 'pending',
2468
2656
  admissionNote: selfAdmits ? AUTO_ADMISSION_NOTE : null,
@@ -2477,7 +2665,7 @@ export class CloudflareScopeHost {
2477
2665
  listVersions: async (actor, verticalSlug, page) => {
2478
2666
  const rows = await this.cp.listVersions(verticalSlug, page);
2479
2667
  await this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
2480
- return rows.map(mapVersion);
2668
+ return rows.map(mapListedVersion);
2481
2669
  },
2482
2670
  getVersion: async (actor, versionId, verticalSlug) => {
2483
2671
  const row = await this.cp.readVersion(versionId);
@@ -2571,6 +2759,19 @@ export class CloudflareScopeHost {
2571
2759
  await this.cp.setAdmission(versionId, 'admitted', null);
2572
2760
  await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' });
2573
2761
  },
2762
+ promotionImpact: async (actor, verticalSlug, channel, versionId) => {
2763
+ const incoming = await this.cp.readVersion(versionId);
2764
+ if (!incoming || incoming.vertical_slug !== verticalSlug) {
2765
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
2766
+ }
2767
+ const current = await this.cp.readChannel(verticalSlug, channel);
2768
+ const outgoing = current ? await this.cp.readVersion(current.version_id) : undefined;
2769
+ const breaks = outgoing
2770
+ ? await this.exportBreaksBetween(actor, verticalSlug, outgoing.manifest_json, incoming.manifest_json)
2771
+ : [];
2772
+ await this.recordAccess(actor, 'promotionImpact', { tenantId: null }, { verticalSlug, channel, versionId }, breaks.length);
2773
+ return breaks;
2774
+ },
2574
2775
  rejectVersion: async (actor, versionId, note) => {
2575
2776
  const v = await this.cp.readVersion(versionId);
2576
2777
  if (!v)
@@ -2607,6 +2808,12 @@ export class CloudflareScopeHost {
2607
2808
  throw new Error(`promotion changes migrations (${outgoing.migration_digest} → ` +
2608
2809
  `${incoming.migration_digest}) — acknowledge it explicitly to promote`);
2609
2810
  }
2811
+ // #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
2812
+ if (!ack.exportBreak) {
2813
+ const breaks = await this.exportBreaksBetween(actor, verticalSlug, outgoing.manifest_json, incoming.manifest_json);
2814
+ if (breaks.length > 0)
2815
+ throw substratError('precondition_failed', exportBreakRefusal(breaks));
2816
+ }
2610
2817
  }
2611
2818
  const promotedAt = new Date().toISOString();
2612
2819
  await this.cp.setChannel(verticalSlug, channel, versionId, promotedAt);
@@ -2688,21 +2895,21 @@ export class CloudflareScopeHost {
2688
2895
  at: r.at,
2689
2896
  }));
2690
2897
  },
2898
+ bindingImpact: async (actor, tenantId, scopeId, versionId, opts) => {
2899
+ const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
2900
+ const breaks = await this.bindBreaks(actor, mapScope(scope), v, opts?.servingRef);
2901
+ await this.recordAccess(actor, 'bindingImpact', { tenantId, scopeId }, { versionId, ...opts }, breaks.length);
2902
+ return breaks;
2903
+ },
2691
2904
  bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
2692
- const v = await this.cp.readVersion(versionId);
2693
- if (!v)
2694
- throw substratError('not_found', `unknown version ${versionId}`);
2695
- const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2696
- if (!scope)
2697
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2698
- // The refusal the registry exists for — but scoped to a SERVING bind. Admission
2699
- // gates code reaching an install; a PREVIEW fork is the builder's own tenant's data
2700
- // at a non-canonical URL, serving no install, so it may run pending PR code — the
2701
- // same own-tenant blast radius that lets a private vertical self-admit. This is what
2702
- // lets a LISTED vertical's builder still preview their own new code (marketplace-publish.md
2703
- // §2; issue #509 ask (d)). Every other scope kind keeps the refusal.
2704
- if (v.admission !== 'admitted' && scope.kind !== 'preview') {
2705
- throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
2905
+ const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
2906
+ const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
2907
+ // #1756: an export an app in this tenant imports, dropped or re-versioned by what this
2908
+ // scope would run. Before the snapshot, so a refused bind leaves nothing behind.
2909
+ if (!ack.exportBreak) {
2910
+ const breaks = await this.bindBreaks(actor, mapScope(scope), v);
2911
+ if (breaks.length > 0)
2912
+ throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
2706
2913
  }
2707
2914
  // Fork-before-promote (§4): snapshot the pre-migration data if this rebind
2708
2915
  // crosses a migration boundary. Gated on a real digest change and on opt-in.
@@ -2715,6 +2922,7 @@ export class CloudflareScopeHost {
2715
2922
  await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug);
2716
2923
  await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
2717
2924
  versionId, vertical: v.vertical_slug, version: v.version,
2925
+ ...(ack.exportBreak ? { acknowledged: ack } : {}),
2718
2926
  });
2719
2927
  },
2720
2928
  /**
@@ -2728,7 +2936,7 @@ export class CloudflareScopeHost {
2728
2936
  markScopeProvisioned: async (actor, tenantId, scopeId, versionId) => {
2729
2937
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2730
2938
  if (!scope)
2731
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2939
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2732
2940
  await this.cp.markScopeProvisioned(scopeId, versionId);
2733
2941
  await this.recordAdmin(actor, 'markScopeProvisioned', { tenantId, scopeId }, null, {
2734
2942
  versionId,
@@ -2771,24 +2979,41 @@ export class CloudflareScopeHost {
2771
2979
  await this.recordAccess(actor, 'versionManifest', {}, { verticalSlug, versionId }, v.manifest_json ? 1 : 0);
2772
2980
  return v.manifest_json;
2773
2981
  },
2774
- setScopeServingRef: async (actor, tenantId, scopeId, servingRef) => {
2982
+ versionMigrations: async (actor, verticalSlug, versionId) => {
2983
+ const v = await this.cp.readVersionMigrations(versionId);
2984
+ if (!v || v.verticalSlug !== verticalSlug) {
2985
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
2986
+ }
2987
+ await this.recordAccess(actor, 'versionMigrations', {}, { verticalSlug, versionId }, v.migrations?.length ?? 0);
2988
+ return v.migrations;
2989
+ },
2990
+ setScopeServingRef: async (actor, tenantId, scopeId, servingRef, opts) => {
2775
2991
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2776
2992
  if (!scope)
2777
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
2993
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2994
+ const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
2995
+ // #1756: onto (or off) a serving script, the scope runs other code before its pointer
2996
+ // moves. Judged here, so no caller that routes first and binds second passes unasked.
2997
+ const bound = scope.vertical_version_id ? await this.cp.readVersion(scope.vertical_version_id) : undefined;
2998
+ if (!ack.exportBreak && bound) {
2999
+ const breaks = await this.bindBreaks(actor, mapScope(scope), bound, servingRef);
3000
+ if (breaks.length > 0)
3001
+ throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
3002
+ }
2778
3003
  await this.cp.setScopeServingRef(scopeId, servingRef);
2779
- await this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref ?? null }, { servingRef });
3004
+ await this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref ?? null }, { servingRef, ...(ack.exportBreak ? { acknowledged: ack } : {}) });
2780
3005
  },
2781
3006
  setScopeExpiresAt: async (actor, tenantId, scopeId, expiresAt) => {
2782
3007
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2783
3008
  if (!scope)
2784
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
3009
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2785
3010
  await this.cp.setScopeExpiresAt(scopeId, expiresAt);
2786
3011
  await this.recordAdmin(actor, 'setScopeExpiresAt', { tenantId, scopeId }, { expiresAt: scope.expires_at ?? null }, { expiresAt });
2787
3012
  },
2788
3013
  scopeAppliedMigrations: async (actor, tenantId, scopeId) => {
2789
3014
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2790
3015
  if (!scope)
2791
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
3016
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2792
3017
  const applied = await this.scopeStub(scopeId).appliedMigrations();
2793
3018
  await this.recordAccess(actor, 'scopeAppliedMigrations', { tenantId, scopeId }, null, applied.length);
2794
3019
  return applied;
@@ -2796,7 +3021,7 @@ export class CloudflareScopeHost {
2796
3021
  scopeMigrationBookmarks: async (actor, tenantId, scopeId) => {
2797
3022
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2798
3023
  if (!scope)
2799
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
3024
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2800
3025
  const bookmarks = await this.scopeStub(scopeId).migrationBookmarks();
2801
3026
  await this.recordAccess(actor, 'scopeMigrationBookmarks', { tenantId, scopeId }, null, bookmarks.length);
2802
3027
  return bookmarks;
@@ -2811,7 +3036,7 @@ export class CloudflareScopeHost {
2811
3036
  rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
2812
3037
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2813
3038
  if (!scope)
2814
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
3039
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2815
3040
  // Audit FIRST: a destructive rewind that fails halfway must still be on the
2816
3041
  // record — the entry names the intent; the DO's refusals name the outcome.
2817
3042
  await this.recordAdmin(actor, 'rewindScope', { tenantId, scopeId }, null, {
@@ -2995,6 +3220,7 @@ export class CloudflareScopeHost {
2995
3220
  await this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, state.cursors.length);
2996
3221
  return state;
2997
3222
  },
3223
+ moveImportCursor: async (actor, tenantId, scopeId, raw) => this.moveImportCursorAt(actor, tenantId, scopeId, raw),
2998
3224
  readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
2999
3225
  const record = await this.scopeRecordForRead(tenantId, scopeId);
3000
3226
  const bounded = Math.min(Math.max(limit ?? 200, 1), 1000);
@@ -3264,7 +3490,7 @@ export class CloudflareScopeHost {
3264
3490
  // no-op when the bytes lived remotely.
3265
3491
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
3266
3492
  if (!rec)
3267
- throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
3493
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
3268
3494
  if (rec.status !== 'archived') {
3269
3495
  throw new Error(`scope ${scopeId} is ${rec.status}, not archived — only an archived scope may be reaped`);
3270
3496
  }
@@ -3906,9 +4132,18 @@ export class CloudflareScopeHost {
3906
4132
  }
3907
4133
  // -- helpers --------------------------------------------------------------
3908
4134
  /**
3909
- * Record a staff read (K-24). `params` is a bounded summary, capped so one query
3910
- * cannot write an unbounded row.
4135
+ * The getScope gate (control-plane.md §4.1/§4.2) — the pair check, then the tenant's and
4136
+ * the scope's lifecycle — thrown on THIS side of the RPC (#1718). The ControlPlaneDO
4137
+ * answers its refusal as data, because an error thrown there arrives here flattened, with
4138
+ * no code; a record crosses intact. A CP-less host's null control plane answers nothing,
4139
+ * so the gate passes: the router already made it from the shared directory.
3911
4140
  */
4141
+ async validateScopeAccess(tenantId, scopeId) {
4142
+ const refusal = await this.cp.scopeAccessRefusal(tenantId, scopeId);
4143
+ if (!refusal)
4144
+ return;
4145
+ throw refusal.code ? substratError(refusal.code, refusal.message) : new Error(refusal.message);
4146
+ }
3912
4147
  /**
3913
4148
  * K-3's cross-check on its own: the (tenant, scope) pair must exist and agree before a
3914
4149
  * subject-key operation touches anything. Without it a caller could reach another
@@ -3917,7 +4152,7 @@ export class CloudflareScopeHost {
3917
4152
  async assertScope(tenantId, scopeId) {
3918
4153
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
3919
4154
  if (!rec)
3920
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
4155
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3921
4156
  }
3922
4157
  /**
3923
4158
  * The same K-3 cross-check, for a read that then opens the scope's STORAGE — every
@@ -3932,7 +4167,7 @@ export class CloudflareScopeHost {
3932
4167
  async scopeRecordForRead(tenantId, scopeId) {
3933
4168
  const row = await this.cp.getScopeRecord(tenantId, scopeId);
3934
4169
  if (!row)
3935
- throw new Error(`unknown scope for tenant: (${tenantId}, ${scopeId})`);
4170
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
3936
4171
  if (row.status === 'reaped') {
3937
4172
  throw new Error(`scope ${scopeId} is reaped — its storage is gone and cannot be read`);
3938
4173
  }
@@ -3950,6 +4185,10 @@ export class CloudflareScopeHost {
3950
4185
  tombstone: (subjectId, at) => this.cp.tombstoneSubjectKey({ scopeId, subjectId, tenantId, at }),
3951
4186
  });
3952
4187
  }
4188
+ /**
4189
+ * Record a staff read (K-24). `params` is a bounded summary, capped so one query
4190
+ * cannot write an unbounded row.
4191
+ */
3953
4192
  async recordAccess(actor, method, target, params, resultCount) {
3954
4193
  await this.cp.recordAccess({
3955
4194
  id: ulid(),
@@ -3985,7 +4224,7 @@ export class CloudflareScopeHost {
3985
4224
  * directory's view stays on `HostAdmin.listConnectionGrants`.
3986
4225
  */
3987
4226
  async connectionGrantsInScope(tenantId, scopeId) {
3988
- await this.cp.validateScopeAccess(tenantId, scopeId);
4227
+ await this.validateScopeAccess(tenantId, scopeId);
3989
4228
  await this.migrateAndRecord(scopeId);
3990
4229
  const now = new Date().toISOString();
3991
4230
  // Both stores again, but the CF split is not the pure adapter's. The DO holds the
@@ -4161,7 +4400,7 @@ export class CloudflareScopeHost {
4161
4400
  * count to the directory — so a scope whose first contact after a deploy is a
4162
4401
  * subscription does not go dark in the migration fleet view.
4163
4402
  */
4164
- await this.cp.validateScopeAccess(tenantId, scopeId);
4403
+ await this.validateScopeAccess(tenantId, scopeId);
4165
4404
  await this.migrateAndRecord(scopeId);
4166
4405
  // Asserted, not carried through from the client: the principal is the
4167
4406
  // vertical's own resolution of its session, and the tenant and scope are the
@@ -4576,6 +4815,185 @@ export class CloudflareScopeHost {
4576
4815
  async peerSwitchLocal(scopeId, vertical, to) {
4577
4816
  return peerSwitchOutcome.parse(await this.scopeStub(scopeId).switchPeer(verticalSlug.parse(vertical), scopeId, to, new Date().toISOString()));
4578
4817
  }
4818
+ // -- the cross-vertical far ends (#1705 PR 2) --------------------------------
4819
+ // The shared control plane runs the cross-vertical phase for every hosted edge and reaches the
4820
+ // scopes over `/internal/exported-events`, `/internal/import-state` and `/internal/import-events`,
4821
+ // which land here. The platform resolved the pair and the tenant; what runs HERE is what only
4822
+ // this deployment can decide: what its own code exports and to whom, and what its own code
4823
+ // imports and runs. Each verb first proves the scope is one this deployment serves
4824
+ // (`assertServesLocally`), because a CP-less host has no directory and an unprovisioned DO
4825
+ // answers every read with a plausible empty result.
4826
+ /** The producer's release after a watermark (#1705), for a scope served HERE. */
4827
+ async exportedEventsLocal(tenantId, scopeId, raw) {
4828
+ const input = exportReadInput.parse(raw);
4829
+ await this.assertServesLocally(tenantId, scopeId, 'readExportedEvents');
4830
+ return exportedBatch.parse(await this.scopeStub(scopeId).exportedEventsRead(input, tenantId, scopeId));
4831
+ }
4832
+ /**
4833
+ * The consumer's imports and watermarks (#1705), for a scope served HERE. The served-here
4834
+ * check comes FIRST, before the "this deployment imports nothing" answer. That answer is a
4835
+ * fact about this code, but given for a scope this deployment does not serve, it would tell the
4836
+ * platform the scope imports nothing, when the truth is that the platform asked the wrong
4837
+ * deployment. The platform reads the former as a disagreement with its registry, and must
4838
+ * hear the latter as a refusal.
4839
+ */
4840
+ async importStateLocal(tenantId, scopeId) {
4841
+ await this.assertServesLocally(tenantId, scopeId, 'importState');
4842
+ if (this.crossVertical.consumes().length === 0)
4843
+ return { consumes: [], cursors: [] };
4844
+ return importState.parse(await this.scopeStub(scopeId).importStateRead());
4845
+ }
4846
+ /** Apply a producer's batch (#1705) to a scope served HERE, through the peer door's gate. */
4847
+ async importEventsLocal(tenantId, scopeId, batch) {
4848
+ await this.assertServesLocally(tenantId, scopeId, 'deliverToPeer');
4849
+ return this.deliverToPeer(tenantId, scopeId, batch);
4850
+ }
4851
+ /**
4852
+ * The replay lever's far end (#1705 PR 3), for a consumer scope served HERE. The platform
4853
+ * resolved the producer and wrote the intent row. What runs here is the move itself, in this
4854
+ * deployment's own store, under the platform's `replayId`. Nothing is audited here, as for the
4855
+ * switches: the admin rows are the platform's.
4856
+ */
4857
+ async importCursorLocal(tenantId, scopeId, raw) {
4858
+ const input = importCursorMoveAt.parse(raw);
4859
+ await this.assertServesLocally(tenantId, scopeId, 'moveImportCursor');
4860
+ return this.moveInScope(scopeId, input);
4861
+ }
4862
+ /** The move in this host's own scope DO, on the real clock (host code may read it). */
4863
+ async moveInScope(scopeId, at) {
4864
+ return importCursorMoved.parse(await this.scopeStub(scopeId).importCursorMove({ ...at, now: Date.now() }));
4865
+ }
4866
+ /**
4867
+ * `HostAdmin.moveImportCursor` (#1705 PR 3): the peer switch's shape. Resolve, audit the
4868
+ * intent, move where the scope's storage is (the delegation for a hosted scope, this host's
4869
+ * own DO otherwise), and audit the outcome. Every attempt leaves a row, and a failed one
4870
+ * says why.
4871
+ */
4872
+ async moveImportCursorAt(actor, tenantId, scopeId, raw) {
4873
+ const move = importCursorMove.parse(raw);
4874
+ if (this.cpLess) {
4875
+ throw substratError('unavailable', 'moveImportCursor needs the directory to resolve the producer — the platform moves a watermark, ' +
4876
+ 'and reaches this deployment through /internal/import-cursor');
4877
+ }
4878
+ const rec = await this.cp.getScopeRecord(tenantId, scopeId);
4879
+ if (!rec)
4880
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
4881
+ const source = await importCursorSourceOf((t, v) => this.admin.resolveVerticalInstance(t, v), { tenantId, scopeId, vertical: rec.vertical }, move.from);
4882
+ // Where the write lands is the peer switch's rule: a scope bound to a vertical is served by
4883
+ // that vertical's deployment, and its watermark lives there. Without a delegation that
4884
+ // refusal stands (`assertServedHere`) rather than a move in the placeholder namespace.
4885
+ const delegation = rec.vertical !== null ? this.importCursorDelegation : undefined;
4886
+ if (!delegation)
4887
+ this.assertServedHere(rec, scopeId, 'moveImportCursor');
4888
+ await this.validateScopeAccess(tenantId, scopeId);
4889
+ const replayId = ulid();
4890
+ const target = { tenantId, scopeId, vertical: rec.vertical };
4891
+ const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
4892
+ await this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
4893
+ const at = { move, source: source, replayId };
4894
+ let moved;
4895
+ try {
4896
+ moved = delegation
4897
+ ? importCursorMoved.parse(await delegation.move({ tenantId, scopeId, at }))
4898
+ : await this.moveInScope(scopeId, at);
4899
+ }
4900
+ catch (err) {
4901
+ await this.recordAdmin(actor, 'moveImportCursor', target, null, {
4902
+ ...base,
4903
+ phase: 'failed',
4904
+ error: err instanceof Error ? err.message : String(err),
4905
+ }).catch(() => undefined);
4906
+ throw err;
4907
+ }
4908
+ await this.recordAdmin(actor, 'moveImportCursor', target, null, {
4909
+ ...base,
4910
+ phase: 'applied',
4911
+ previous: moved.previous,
4912
+ cursor: moved.cursor,
4913
+ archived: moved.archived,
4914
+ });
4915
+ return moved;
4916
+ }
4917
+ /** #1705 PR 3: the promote gate's question, over two stored manifests (`exportBreaksOf`). */
4918
+ exportBreaksBetween(actor, producer, outgoingManifest, incomingManifest) {
4919
+ return exportBreaksOf({
4920
+ admin: this.admin,
4921
+ actor,
4922
+ producer,
4923
+ outgoing: exportsOfManifestJson(outgoingManifest),
4924
+ incoming: exportsOfManifestJson(incomingManifest),
4925
+ readImports: (slug, versionId) => this.versionImports(slug, versionId),
4926
+ });
4927
+ }
4928
+ /**
4929
+ * #1756: the bind gate's question for one install (`bindExportBreaksOf`), from the directory.
4930
+ * The serving pointer is read only when the scope is on a serving script before or after the
4931
+ * move, the one case it decides anything, and the incoming version's manifest comes from the
4932
+ * row the caller already holds.
4933
+ */
4934
+ async bindBreaks(actor, scope, incoming, servingRef) {
4935
+ const vertical = scope.vertical && (scope.servingRef || servingRef) ? await this.cp.readVertical(scope.vertical) : undefined;
4936
+ return bindExportBreaksOf({
4937
+ admin: this.admin,
4938
+ actor,
4939
+ scope,
4940
+ incoming: { id: incoming.id, verticalSlug: incoming.vertical_slug },
4941
+ ...(servingRef !== undefined ? { servingRef } : {}),
4942
+ serving: vertical?.serving_ref && vertical.serving_version_id
4943
+ ? { ref: vertical.serving_ref, versionId: vertical.serving_version_id }
4944
+ : null,
4945
+ readExports: async (versionId) => exportsOfManifestJson(versionId === incoming.id ? incoming.manifest_json : ((await this.cp.readVersion(versionId))?.manifest_json ?? null)),
4946
+ readImports: (slug, versionId) => this.versionImports(slug, versionId),
4947
+ });
4948
+ }
4949
+ /**
4950
+ * The served-here gate (#1705 PR 2), from whichever source of truth this host has.
4951
+ *
4952
+ * With a directory, the directory decides: the record must exist for this (tenant, scope), and
4953
+ * the shared control plane refuses a scope bound to a vertical (`assertServedHere`). Role rows
4954
+ * are not consulted, because a directory-backed host provisions without projecting them.
4955
+ *
4956
+ * CP-less, which is every pushed vertical, there is no directory. The scope must have been
4957
+ * provisioned in THIS deployment's namespace, for THIS tenant (`ScopeDO.servesTenant`).
4958
+ *
4959
+ * A scope this host does not hold is refused `conflict`, not `not_found`: over `/internal` a
4960
+ * 404 means "this deployment predates the route", and the platform reads it as exactly that.
4961
+ * One exception: the SHARED control plane (a directory host with delegations) refuses a scope
4962
+ * bound to a vertical `unavailable`, through `assertServedHere`, the same answer its peer door
4963
+ * gives. It never mounts these routes, so no `/internal` caller meets that answer.
4964
+ */
4965
+ async assertServesLocally(tenantId, scopeId, verb) {
4966
+ if (!this.cpLess) {
4967
+ const record = await this.cp.getScopeRecord(tenantId, scopeId);
4968
+ if (!record) {
4969
+ throw substratError('conflict', `${verb} cannot answer for scope ${scopeId}: the directory has no such scope for tenant ${tenantId}`);
4970
+ }
4971
+ this.assertServedHere(record, scopeId, verb);
4972
+ return;
4973
+ }
4974
+ if (!(await this.scopeStub(scopeId).servesTenant(tenantId))) {
4975
+ throw substratError('conflict', `${verb} cannot answer for scope ${scopeId}: this deployment holds no scope provisioned for tenant ` +
4976
+ `${tenantId} under that id — the platform resolved the scope to a deployment that does not serve it`);
4977
+ }
4978
+ }
4979
+ /**
4980
+ * What one pushed version imports from other verticals (#1705 PR 2), from its stored manifest:
4981
+ * the control plane's cross-vertical narrowing reads this to decide which scopes it calls.
4982
+ *
4983
+ * Deliberately NOT through `admin.versionManifest`, which writes an access-log row per read.
4984
+ * The narrowing asks once per distinct running version on every pass and every kick. Audited,
4985
+ * the log would grow with fleet × tick rate + request rate, for a read of the platform's own
4986
+ * code metadata (a version's declared edges, which a push put there), not of a tenant's data.
4987
+ * Throws `not_found` for a version the registry does not know under that vertical, which the
4988
+ * narrowing reports and treats as "cannot say" rather than "imports nothing".
4989
+ */
4990
+ async versionImports(verticalSlug, versionId) {
4991
+ const v = await this.cp.readVersion(versionId);
4992
+ if (!v || v.vertical_slug !== verticalSlug) {
4993
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
4994
+ }
4995
+ return importsOfManifestJson(v.manifest_json);
4996
+ }
4579
4997
  }
4580
4998
  /** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
4581
4999
  async function sha256Hex(body) {