@substrat-run/adapter-cloudflare 0.117.0 → 0.119.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, 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, 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, outboundOfManifestJson, substratError, redrainEventsInput, } from '@substrat-run/contracts';
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';
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, } 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, systemSwitchedOffMessage, CrossVerticalRegistry, 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';
@@ -22,6 +22,12 @@ const toConnection = (r) => connection.parse({
22
22
  createdAt: r.created_at,
23
23
  revokedAt: r.revoked_at,
24
24
  });
25
+ /** Rethrow a capability attachment verb's failure, rebuilt with its code; else its value. */
26
+ function unwrapCapabilityReply(reply) {
27
+ if (reply.failure)
28
+ throw fromWireFailure(reply.failure);
29
+ return reply.value;
30
+ }
25
31
  /**
26
32
  * A control-plane stand-in for a CP-less vertical (scope-local-permissions.md Phase 3).
27
33
  * The hot path a served scope actually touches becomes trust-the-upstream:
@@ -63,8 +69,17 @@ export class CloudflareScopeHost {
63
69
  // Registration-mechanics bookkeeping (validation only — the DO executes).
64
70
  // Code-time, derived from the bundled modules, NOT durable directory state.
65
71
  moduleIds = new Set();
72
+ /**
73
+ * #1705: this deployment's cross-vertical declarations. The DO holds the handlers and makes
74
+ * every decision. The coordinator keeps its own copy for two reasons: registration refuses
75
+ * bad wiring at the same point as the pure host, and `importState` can answer "imports
76
+ * nothing" without waking a DO.
77
+ */
78
+ crossVertical = new CrossVerticalRegistry();
66
79
  /** Module id → its declared recurring schedules (#383), for `registeredSchedules`/`runDueSchedules`. */
67
80
  moduleSchedules = new Map();
81
+ /** #1706: every registered module's `peers`, as declared — read as one union per peer. */
82
+ peerSources = [];
68
83
  /** #1232: declared freshness per module. Registered UNCONDITIONALLY — the schedule
69
84
  * map is only populated for modules WITH schedules, and hanging freshness off it
70
85
  * would drop a freshness-only module at registration. */
@@ -76,6 +91,7 @@ export class CloudflareScopeHost {
76
91
  /** Executor id → {eventType, handler} (K-22 §4.2). Coordinator-side, not in the DO. */
77
92
  secretBox;
78
93
  fetchImpl;
94
+ connectorCalls;
79
95
  /** The live D1 client for per-tenant stores (#301); undefined ⇒ refuse loudly. */
80
96
  tenantStores;
81
97
  /** The live R2 client for per-tenant blob stores (#473); undefined ⇒ refuse loudly. */
@@ -106,6 +122,8 @@ export class CloudflareScopeHost {
106
122
  eventDrainDelegation;
107
123
  /** #1666: the schedule kill switch's reach into the deployment serving a scope. */
108
124
  systemSwitchDelegation;
125
+ /** #1706: the peer kill switch's reach into the deployment serving a scope. */
126
+ peerSwitchDelegation;
109
127
  /**
110
128
  * MUST be constructed per request. Never cache an instance across requests.
111
129
  *
@@ -123,6 +141,7 @@ export class CloudflareScopeHost {
123
141
  this.blobStores = options.blobStores;
124
142
  this.attachmentBuckets = options.attachmentBuckets;
125
143
  this.fetchImpl = options.fetch ?? globalFetch;
144
+ this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
126
145
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
127
146
  this.scopeNs = options.scope;
128
147
  this.cpLess = !options.controlPlane;
@@ -132,6 +151,7 @@ export class CloudflareScopeHost {
132
151
  this.connectorDelegation = options.connectorDelegation;
133
152
  this.eventDrainDelegation = options.eventDrainDelegation;
134
153
  this.systemSwitchDelegation = options.systemSwitchDelegation;
154
+ this.peerSwitchDelegation = options.peerSwitchDelegation;
135
155
  this.admin = this.buildAdmin();
136
156
  }
137
157
  // -- registration mechanics (validation only) -----------------------------
@@ -206,19 +226,18 @@ export class CloudflareScopeHost {
206
226
  // request pending across a rotation still opens.
207
227
  unseal: async (sealed) => openSealed(await this.openSealingKeys(open.id), sealed),
208
228
  fetch: async (input, init) => {
229
+ // #1691: timed where the call is made, so the data point carries a duration.
230
+ const started = Date.now();
209
231
  try {
210
232
  const res = await fetchImpl(input, {
211
233
  ...init,
212
234
  signal: AbortSignal.timeout(timeoutMs),
213
235
  });
214
- await admin.recordConnectionUse(open.id, res.ok ? { ok: true } : { ok: false, error: `HTTP ${res.status} from ${provider}` });
236
+ await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { response: res }));
215
237
  return res;
216
238
  }
217
239
  catch (err) {
218
- await admin.recordConnectionUse(open.id, {
219
- ok: false,
220
- error: err instanceof Error ? err.message : String(err),
221
- });
240
+ await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { error: err }));
222
241
  throw err;
223
242
  }
224
243
  },
@@ -667,7 +686,8 @@ export class CloudflareScopeHost {
667
686
  }
668
687
  seen.add(m.version);
669
688
  }
670
- const declaredConsumes = new Set(manifest.events.consumes.map((c) => c.type));
689
+ // IN-SCOPE consumes only (#1705): a `from` entry is an import, handled under `imports`.
690
+ const declaredConsumes = new Set(manifest.events.consumes.filter((c) => c.from === undefined).map((c) => c.type));
671
691
  for (const eventType of Object.keys(registration.consumers ?? {})) {
672
692
  if (!declaredConsumes.has(eventType)) {
673
693
  throw new Error(`${manifest.id} registers a consumer for undeclared event type: ${eventType}`);
@@ -680,10 +700,15 @@ export class CloudflareScopeHost {
680
700
  }
681
701
  this.predicateNames.set(name, manifest.id);
682
702
  }
703
+ // #1705: after the checks above, so a refused module leaves nothing registered here.
704
+ this.crossVertical.register(manifest, registration.imports);
683
705
  this.moduleIds.add(manifest.id);
684
706
  if (manifest.schedules && manifest.schedules.length > 0) {
685
707
  this.moduleSchedules.set(manifest.id, manifest.schedules);
686
708
  }
709
+ if (manifest.peers && manifest.peers.length > 0) {
710
+ this.peerSources.push({ peers: manifest.peers });
711
+ }
687
712
  if (manifest.freshness && manifest.freshness.length > 0) {
688
713
  this.moduleFreshness.set(manifest.id, manifest.freshness);
689
714
  }
@@ -751,6 +776,13 @@ export class CloudflareScopeHost {
751
776
  await this.scopeStub(input.scopeId).seatTuple(`system:${moduleId}`, `granted:${perm}`, `scope:${input.scopeId}`, null);
752
777
  }
753
778
  }
779
+ // #1706: every declared PEER holds its keys on the scope from provisioning on — the one
780
+ // call (`peerSeats`) that makes "declared and installed" mean "live". Same seat as the
781
+ // schedule grants above, so a grant a switch tombstoned stays tombstoned and a
782
+ // switched-off peer gets nothing seated.
783
+ for (const seat of peerSeats(collectPeers(this.peerSources), input.scopeId)) {
784
+ await this.scopeStub(input.scopeId).seatTuple(seat.subject, seat.relation, seat.object, null);
785
+ }
754
786
  // Audit a real provision only; an idempotent re-provision changed nothing.
755
787
  if (created) {
756
788
  await this.recordAdmin(actor, 'provisionScope', { tenantId: input.tenantId, scopeId: input.scopeId, vertical: record.vertical }, null, record);
@@ -966,6 +998,27 @@ export class CloudflareScopeHost {
966
998
  buildAttachmentSurface(subject, tenantId, scopeId, store,
967
999
  /** The delivery admitting this surface's reads (#726 remedy B), when there is one. */
968
1000
  forEvent) {
1001
+ const stub = this.scopeStub(scopeId);
1002
+ if ('capabilitySession' in subject) {
1003
+ // #1686: a capability session. Its own ScopeDO verbs rather than a trailing argument
1004
+ // on the principal ones, so a DO that predates them fails the call (no such RPC
1005
+ // method) instead of ignoring the session and checking as someone else. Writes go to
1006
+ // the DO only to be refused and recorded — never to the blob store.
1007
+ const hash = subject.capabilitySession;
1008
+ return {
1009
+ upload: async (input) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.upload', {
1010
+ entityType: input.entity.entityType,
1011
+ })),
1012
+ list: async (entity) => unwrapCapabilityReply(await stub.capabilityAttachmentList(entity, hash, tenantId, scopeId)),
1013
+ open: async (attachmentId) => {
1014
+ const record = unwrapCapabilityReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
1015
+ return record ? this.openAttachmentBytes(store, scopeId, record) : null;
1016
+ },
1017
+ remove: async (attachmentId) => unwrapCapabilityReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
1018
+ attachmentId,
1019
+ })),
1020
+ };
1021
+ }
969
1022
  const connectionId = 'connectionId' in subject ? subject.connectionId : undefined;
970
1023
  const createdBy = 'principal' in subject ? subject.principal : subject.connectionId;
971
1024
  // The DO needs SOME principal-shaped value for `ctx.principal`; for a connection it is
@@ -974,11 +1027,6 @@ export class CloudflareScopeHost {
974
1027
  const asPrincipalId = ('principal' in subject
975
1028
  ? subject.principal
976
1029
  : subject.connectionId);
977
- const stub = this.scopeStub(scopeId);
978
- const sha256Hex = async (body) => {
979
- const digest = await crypto.subtle.digest('SHA-256', body);
980
- return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
981
- };
982
1030
  return {
983
1031
  upload: async (input) => {
984
1032
  const id = ulid();
@@ -1008,17 +1056,7 @@ export class CloudflareScopeHost {
1008
1056
  list: (entity) => stub.attachmentList(entity, asPrincipalId, tenantId, scopeId, connectionId),
1009
1057
  open: async (attachmentId) => {
1010
1058
  const record = await stub.attachmentAuthorize(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId);
1011
- if (!record)
1012
- return null;
1013
- const obj = await store.get(attachmentBlobKey(scopeId, record.id));
1014
- if (!obj) {
1015
- throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
1016
- `survived something the object did not (rewind/reap); see the #473 integrity notes`);
1017
- }
1018
- if ((await sha256Hex(obj.body)) !== record.sha256) {
1019
- throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
1020
- }
1021
- return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
1059
+ return record ? this.openAttachmentBytes(store, scopeId, record) : null;
1022
1060
  },
1023
1061
  remove: async (attachmentId) => {
1024
1062
  const removed = await stub.attachmentRemove(attachmentId, asPrincipalId, tenantId, scopeId, connectionId);
@@ -1028,6 +1066,22 @@ export class CloudflareScopeHost {
1028
1066
  },
1029
1067
  };
1030
1068
  }
1069
+ /**
1070
+ * The bytes of an attachment the ScopeDO has already AUTHORIZED, read from the blob store
1071
+ * and held to the record's sha256 — one path for every subject, so a capability's download
1072
+ * meets the same integrity checks a principal's does.
1073
+ */
1074
+ async openAttachmentBytes(store, scopeId, record) {
1075
+ const obj = await store.get(attachmentBlobKey(scopeId, record.id));
1076
+ if (!obj) {
1077
+ throw new Error(`attachment ${record.id}: bytes missing from the blob store — the metadata row ` +
1078
+ `survived something the object did not (rewind/reap); see the #473 integrity notes`);
1079
+ }
1080
+ if ((await sha256Hex(obj.body)) !== record.sha256) {
1081
+ throw new Error(`attachment ${record.id}: bytes do not match the recorded sha256`);
1082
+ }
1083
+ return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
1084
+ }
1031
1085
  async importScope(actor, input, dump) {
1032
1086
  // Create the destination scope (directory row + DO + lazy migrate); the DO then
1033
1087
  // replaces its provisioned schema with the dump wholesale (drop-then-replay), so
@@ -1227,6 +1281,29 @@ export class CloudflareScopeHost {
1227
1281
  await this.migrateAndRecord(scopeId);
1228
1282
  return this.buildStub(tenantId, scopeId, undefined, undefined, moduleId);
1229
1283
  }
1284
+ /**
1285
+ * #1705: the consumer half. The same lifecycle gate as every door (K-3), then the ScopeDO
1286
+ * applies the batch, and the answer is re-parsed on this side of the RPC. Executors run here
1287
+ * afterwards, as they do after an invoke. A failure to drain them does not un-deliver a batch
1288
+ * that has committed, so it is contained, and the outbox is their backstop.
1289
+ */
1290
+ async deliverToPeer(tenantId, scopeId, raw) {
1291
+ const batch = importBatch.parse(raw);
1292
+ // The peer door's own gate (#1706): K-3's pair check, the refusal for a scope served
1293
+ // elsewhere, the lifecycle check and the migration. A delivery is a write into the
1294
+ // consumer's scope, so it takes the gate an invoke through that door takes.
1295
+ await this.peerScopeGate(tenantId, scopeId, 'deliverToPeer');
1296
+ const result = importResult.parse(await this.scopeStub(scopeId).importApply(batch, tenantId, scopeId));
1297
+ if (result.delivered > 0) {
1298
+ try {
1299
+ await this.drainExecutors(tenantId, scopeId, null);
1300
+ }
1301
+ catch (err) {
1302
+ console.error('substrat: executor drain after a cross-vertical delivery failed', err);
1303
+ }
1304
+ }
1305
+ return result;
1306
+ }
1230
1307
  /**
1231
1308
  * The exchange (#1672) — the same fail-closed (tenant, scope) and lifecycle gate as
1232
1309
  * `getScope`, then the kernel's `exchangeCapability` inside the ScopeDO, where the
@@ -1252,14 +1329,91 @@ export class CloudflareScopeHost {
1252
1329
  const hash = await capabilityTokenHash(sessionToken);
1253
1330
  return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, hash);
1254
1331
  }
1332
+ /**
1333
+ * The peer door (#1706) — mirror of the connection, system and capability doors. `caller`
1334
+ * is the platform's word (the router's, on the hosted path); the pair and lifecycle gate run
1335
+ * here, and the ScopeDO admits the peer inside its queue on every invoke and acknowledges it.
1336
+ */
1337
+ async getVerticalScope(caller, tenantId, scopeId, options) {
1338
+ const parsed = verticalCaller.parse(caller);
1339
+ await this.peerScopeGate(tenantId, scopeId, 'getVerticalScope');
1340
+ return this.buildStub(tenantId, scopeId, undefined, undefined, undefined, options, undefined, undefined, parsed);
1341
+ }
1342
+ /**
1343
+ * Does peer `vertical` hold each key at this scope's node now (#1706) — the ScopeDO's own
1344
+ * checker, so it answers what an invoke would be told, switch included.
1345
+ */
1346
+ async peerCovers(tenantId, scopeId, vertical, permissions) {
1347
+ await this.peerScopeGate(tenantId, scopeId, 'peerCovers');
1348
+ return peerCoverage
1349
+ .array()
1350
+ .parse(await this.scopeStub(scopeId).peerCovers(tenantId, scopeId, verticalSlug.parse(vertical), [...permissions]));
1351
+ }
1352
+ /**
1353
+ * Where a peer verb (#1706) may reach a scope: its own ScopeDO, after the ordinary pair and
1354
+ * lifecycle gate. Refused, loudly, on the SHARED control plane for a scope bound to a
1355
+ * vertical, whose storage lives in that vertical's deployment — reaching through
1356
+ * `this.scopeStub` there would open an empty DO in the wrong namespace. On the hosted path a
1357
+ * peer reaches the target deployment itself, through the platform, never through this host.
1358
+ */
1359
+ async peerScopeGate(tenantId, scopeId, verb) {
1360
+ // K-3's pair check runs HERE, not only inside the ControlPlaneDO (#1714 review). An error
1361
+ // thrown in a Durable Object arrives at the coordinator FLATTENED — its code and
1362
+ // extensions gone — so a typed refusal thrown there reaches a caller untyped, which this
1363
+ // door's own tenant-confinement test proved. A RECORD crosses intact, so the coordinator
1364
+ // reads the record and types the refusal itself. `validateScopeAccess` still runs below
1365
+ // and still owns the lifecycle half (a suspended tenant or scope).
1366
+ if (!this.cpLess) {
1367
+ const record = await this.cp.getScopeRecord(tenantId, scopeId);
1368
+ if (!record) {
1369
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
1370
+ }
1371
+ this.assertServedHere(record, scopeId, verb);
1372
+ }
1373
+ await this.cp.validateScopeAccess(tenantId, scopeId);
1374
+ await this.migrateAndRecord(scopeId);
1375
+ }
1376
+ /**
1377
+ * The capability's attachment door (#1686) — `getCapabilityScope`'s gate, then the
1378
+ * attachment surface with only the session's HASH going on to the ScopeDO. The DO
1379
+ * resolves it inside its queue on every call, checks each read as `{ capability }` and
1380
+ * refuses each write; the bytes stay on this side, as on every attachment path.
1381
+ */
1382
+ async getCapabilityAttachments(sessionToken, tenantId, scopeId) {
1383
+ if (!plausibleSessionToken(sessionToken)) {
1384
+ throw substratError('unauthenticated', 'not a capability session token');
1385
+ }
1386
+ await this.cp.validateScopeAccess(tenantId, scopeId);
1387
+ await this.migrateAndRecord(scopeId);
1388
+ const store = await this.resolveAttachmentStore(tenantId);
1389
+ const capabilitySession = await capabilityTokenHash(sessionToken);
1390
+ return this.buildAttachmentSurface({ capabilitySession }, tenantId, scopeId, store);
1391
+ }
1255
1392
  /**
1256
1393
  * Is this the SHARED control plane's host — the one whose own `SCOPE` namespace holds no
1257
1394
  * hosted scope's storage, and which routes scope writes to the serving deployment? The
1258
1395
  * delegations are set on that host and no other (their own docs say so), so any one of
1259
1396
  * them being present is the signal.
1260
1397
  */
1398
+ /**
1399
+ * Refuse a scope this host does not serve (#1706's door, and #1705's three cross-vertical
1400
+ * verbs).
1401
+ *
1402
+ * On the shared control plane `this.scopeStub` is the module-less placeholder namespace, so
1403
+ * reaching it for a scope bound to a vertical answers from an EMPTY Durable Object: a read
1404
+ * reports a hosted producer as having nothing to export, and a delivery would journal a
1405
+ * batch into a scope that is not the one it names. Both are wrong ANSWERS rather than
1406
+ * failures, which is the shape that goes unnoticed. One refusal, shared by the door's gate
1407
+ * and by the verbs that read a record of their own, so a caller never meets two of them.
1408
+ */
1409
+ assertServedHere(record, scopeId, verb) {
1410
+ if (this.servesScopesElsewhere && record.vertical !== null) {
1411
+ throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${record.vertical}' deployment, ` +
1412
+ 'which a peer reaches through the platform, not through the shared control plane');
1413
+ }
1414
+ }
1261
1415
  get servesScopesElsewhere() {
1262
- return Boolean(this.connectorDelegation || this.systemSwitchDelegation || this.eventDrainDelegation);
1416
+ return Boolean(this.connectorDelegation || this.systemSwitchDelegation || this.peerSwitchDelegation || this.eventDrainDelegation);
1263
1417
  }
1264
1418
  /**
1265
1419
  * Where a platform capability verb (#1672) may write: this host's own ScopeDO — refused,
@@ -1280,6 +1434,10 @@ export class CloudflareScopeHost {
1280
1434
  await this.migrateAndRecord(scopeId);
1281
1435
  return { stub: this.scopeStub(scopeId), vertical: rec.vertical };
1282
1436
  }
1437
+ /** #1705: what this deployment imports — the sweep's reason to call no scope when it is empty. */
1438
+ registeredImports() {
1439
+ return this.crossVertical.consumes();
1440
+ }
1283
1441
  registeredSchedules() {
1284
1442
  const out = [];
1285
1443
  for (const [moduleId, schedules] of this.moduleSchedules) {
@@ -1423,7 +1581,12 @@ export class CloudflareScopeHost {
1423
1581
  * the plaintext goes no further. The ScopeDO resolves it on every invoke, inside its
1424
1582
  * queue, and acknowledges it; see the refusal below for why the acknowledgement matters.
1425
1583
  */
1426
- capabilitySession) {
1584
+ capabilitySession,
1585
+ /**
1586
+ * #1706: the calling PEER vertical, as the platform named it. The ScopeDO admits it on
1587
+ * every invoke, inside its queue, and acknowledges it — the capability session's pattern.
1588
+ */
1589
+ verticalCaller) {
1427
1590
  const stub = this.scopeStub(scopeId);
1428
1591
  const cp = this.cp;
1429
1592
  const operationEntitlement = this.operationEntitlement;
@@ -1432,10 +1595,14 @@ export class CloudflareScopeHost {
1432
1595
  // honest attribution rides on the event actor instead. For a capability it is a
1433
1596
  // FRESH id nobody holds anything under (#1672): the DO replaces it with the resolved
1434
1597
  // capability, and a DO too old to know that would act as nobody rather than as someone.
1598
+ // A peer vertical (#1706) gets a fresh id too, for the same reason: the DO acts as the
1599
+ // admitted `{ vertical, scope }`, and a DO too old to know that acts as nobody.
1435
1600
  const asPrincipalId = (principal ??
1436
1601
  connectionId ??
1437
1602
  systemModuleId ??
1438
- (capabilitySession !== undefined ? principalId.parse(ulid()) : undefined));
1603
+ (capabilitySession !== undefined || verticalCaller !== undefined
1604
+ ? principalId.parse(ulid())
1605
+ : undefined));
1439
1606
  return {
1440
1607
  tenantId,
1441
1608
  scopeId,
@@ -1457,7 +1624,7 @@ export class CloudflareScopeHost {
1457
1624
  }
1458
1625
  // K-42: resolved per invoke, so expiry and `endImpersonation` both bite.
1459
1626
  const session = sessionId === undefined ? undefined : await this.resolveImpersonation(sessionId, tenantId, scopeId);
1460
- const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session, capabilitySession);
1627
+ const envelope = await stub.invoke(operation, input, asPrincipalId, tenantId, scopeId, connectionId, requiredKey, systemModuleId, true, invokeOptions, session, capabilitySession, verticalCaller);
1461
1628
  // The operation failed and the DO handed the error back as DATA — so it still
1462
1629
  // has its code and extensions, which a throw across this boundary would have
1463
1630
  // stripped down to a message (#113 §3). Rethrown here, where the caller expects
@@ -1499,6 +1666,13 @@ export class CloudflareScopeHost {
1499
1666
  throw substratError('unavailable', `${operation} ran on a scope host that did not understand capability sessions. ` +
1500
1667
  'Retry once the scope has been migrated');
1501
1668
  }
1669
+ // #1706, the same shape: a DO too old to know about peer callers ran the call as the
1670
+ // fresh placeholder principal — without the peer's admission, its allowlist or its
1671
+ // switch. A success decided that way is refused.
1672
+ if (verticalCaller !== undefined && envelope.vertical?.honoured !== true) {
1673
+ throw substratError('unavailable', `${operation} ran on a scope host that did not understand peer callers. ` +
1674
+ 'Retry once the scope has been migrated');
1675
+ }
1502
1676
  if (session !== undefined && envelope.impersonation?.honoured !== true) {
1503
1677
  throw substratError('unavailable', `${operation} ran on a scope host that did not understand impersonation — the ` +
1504
1678
  'operation may have run unrecorded and unbounded. Retry once the scope has ' +
@@ -1582,6 +1756,7 @@ export class CloudflareScopeHost {
1582
1756
  admissionNote: r.admission_note,
1583
1757
  origin: r.origin_json ? JSON.parse(r.origin_json) : null,
1584
1758
  outbound: outboundOfManifestJson(r.manifest_json),
1759
+ calls: callsOfManifestJson(r.manifest_json),
1585
1760
  createdAt: r.created_at,
1586
1761
  });
1587
1762
  const mapOrg = (r) => orgSchema.parse({
@@ -1664,6 +1839,77 @@ export class CloudflareScopeHost {
1664
1839
  * switch pulled while every schedule kept firing. The audit row is written HERE either
1665
1840
  * way — the deployment's host is CP-less, and its `recordAdmin` is a no-op.
1666
1841
  */
1842
+ /**
1843
+ * #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. The
1844
+ * schedule switch's audit discipline (intent first, outcome after, every attempt) and,
1845
+ * since part 3, its delegation: a scope served by a vertical's own deployment is switched
1846
+ * THERE, over `PeerSwitchDelegation`. Without one configured the scope is still refused
1847
+ * `unavailable` (`peerScopeGate`) rather than switched in the placeholder namespace.
1848
+ */
1849
+ const switchPeerAt = async (actor, raw, to) => {
1850
+ const input = peerSwitch.parse(raw);
1851
+ const { tenantId, scopeId } = input.node;
1852
+ let vertical = null;
1853
+ if (!this.cpLess) {
1854
+ const rec = await this.cp.getScopeRecord(tenantId, scopeId);
1855
+ if (!rec)
1856
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
1857
+ vertical = rec.vertical;
1858
+ }
1859
+ const action = to === 'off' ? 'revokeFromPeer' : 'restoreToPeer';
1860
+ // Where the write lands is the whole point of this branch (#1706 part 3, mirroring
1861
+ // #1666). A scope bound to a vertical is served by that vertical's own deployment, and
1862
+ // its `vertical:<slug>` grants live there; switching it in the shared control plane's
1863
+ // placeholder namespace would tombstone nothing and report a peer cut off while every
1864
+ // call it makes kept being admitted. A scope bound to NO vertical has its store here,
1865
+ // so it switches here — delegating it would throw "no deployment serving scope".
1866
+ // With no delegation configured the old refusal stands, loudly, rather than a write
1867
+ // into the wrong namespace.
1868
+ const delegation = this.cpLess || vertical !== null ? this.peerSwitchDelegation : undefined;
1869
+ if (delegation) {
1870
+ // The gate's lifecycle half still runs (a suspended tenant or scope cannot be
1871
+ // switched); its `scopeStub` half is what the delegation replaces.
1872
+ await this.cp.validateScopeAccess(tenantId, scopeId);
1873
+ }
1874
+ else {
1875
+ await this.peerScopeGate(tenantId, scopeId, action);
1876
+ }
1877
+ const operationId = ulid();
1878
+ const target = { tenantId, scopeId, vertical };
1879
+ const base = { operationId, vertical: input.vertical, calls: to };
1880
+ await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason: input.reason });
1881
+ let outcome;
1882
+ try {
1883
+ outcome = delegation
1884
+ ? await delegation.switch({ tenantId, scopeId, vertical: input.vertical, to })
1885
+ : await this.scopeStub(scopeId).switchPeer(input.vertical, scopeId, to, new Date().toISOString());
1886
+ }
1887
+ catch (err) {
1888
+ await this.recordAdmin(actor, action, target, null, {
1889
+ ...base,
1890
+ phase: 'failed',
1891
+ error: err instanceof Error ? err.message : String(err),
1892
+ }).catch(() => undefined);
1893
+ throw err;
1894
+ }
1895
+ await this.recordAdmin(actor, action, target, null, {
1896
+ ...base,
1897
+ phase: outcome.held ? 'applied' : 'refused',
1898
+ changed: outcome.changed,
1899
+ permissions: outcome.permissions,
1900
+ });
1901
+ if (!outcome.held) {
1902
+ throw substratError('not_found', `scope ${scopeId} holds no grant for peer '${input.vertical}' — nothing to switch ${to} ` +
1903
+ `(check the slug: it is the calling vertical's registry id, as the target's \`peers\` names it)`);
1904
+ }
1905
+ return {
1906
+ operationId,
1907
+ vertical: input.vertical,
1908
+ calls: to,
1909
+ changed: outcome.changed,
1910
+ permissions: outcome.permissions,
1911
+ };
1912
+ };
1667
1913
  const switchSystem = async (actor, raw, to) => {
1668
1914
  const input = systemSwitch.parse(raw);
1669
1915
  const { tenantId, scopeId } = input.node;
@@ -1723,6 +1969,128 @@ export class CloudflareScopeHost {
1723
1969
  permissions: outcome.permissions,
1724
1970
  };
1725
1971
  };
1972
+ /**
1973
+ * The status read's admin-log join (#1674): the `intent` row of the `revokeFromSystem`
1974
+ * still in force for each OFF module — actor, reason, when. Paired to its `applied` row
1975
+ * by `operationId`, so a refused or failed attempt is never read as the explanation
1976
+ * (`switchSystemSchedules`'s `held` check means one can only occur on a module this
1977
+ * scope has never switched off before, so it can never be what a currently-off module
1978
+ * is explained by — this is belt-and-braces, not a case that is expected to fire).
1979
+ */
1980
+ const lastSwitchedOffBy = async (tenantId, scopeId,
1981
+ /** The revoking action, and the payload field naming its subject (#1706). */
1982
+ action, key, subjects) => {
1983
+ const moduleIds = subjects;
1984
+ // `order: 'desc'` is load-bearing: the loop below takes the FIRST intent it sees per
1985
+ // module as the latest one, and `auditLog`'s own default is 'asc' (oldest first).
1986
+ const rows = await this.cp.auditLog({ tenantId, scopeId, action: [action], order: 'desc' });
1987
+ const appliedOps = new Set();
1988
+ for (const row of rows) {
1989
+ const payload = row.after;
1990
+ if (payload?.phase === 'applied' && payload.operationId)
1991
+ appliedOps.add(payload.operationId);
1992
+ }
1993
+ const result = new Map();
1994
+ for (const row of rows) {
1995
+ if (result.size === moduleIds.size)
1996
+ break;
1997
+ const payload = row.after;
1998
+ const subject = payload?.[key];
1999
+ if (!payload || payload.phase !== 'intent' || !payload.operationId || typeof subject !== 'string')
2000
+ continue;
2001
+ if (!moduleIds.has(subject) || result.has(subject))
2002
+ continue;
2003
+ if (!appliedOps.has(String(payload.operationId)) || typeof payload.reason !== 'string')
2004
+ continue;
2005
+ result.set(subject, { actor: row.actor, reason: payload.reason, at: row.at });
2006
+ }
2007
+ return result;
2008
+ };
2009
+ /** #1674's join, bound to the schedule switch — the shape every caller here used. */
2010
+ const lastSwitchedOff = async (tenantId, scopeId, moduleIds) => lastSwitchedOffBy(tenantId, scopeId, 'revokeFromSystem', 'moduleId', moduleIds);
2011
+ /**
2012
+ * The status read (#1674): every module this scope holds or has held system authority
2013
+ * for, and where each stands — the SAME predicate `switchSystem` gates its writes on
2014
+ * (`systemScheduleState`, walked over every held subject by the kernel's
2015
+ * `systemGrantsStatus`), so the read and the runner cannot disagree. For a hosted
2016
+ * scope, delegated exactly as the write is (see `switchSystem` above): a deployment
2017
+ * built before #1666's route answers the same "redeploy the vertical", never a wrong
2018
+ * `on`. The admin-log join happens HERE only — never on the deployment, which holds
2019
+ * no admin log of its own.
2020
+ */
2021
+ const systemGrantsStatusOf = async (actor, node) => {
2022
+ const { tenantId, scopeId } = node;
2023
+ let vertical = null;
2024
+ if (!this.cpLess) {
2025
+ const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2026
+ if (!rec)
2027
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2028
+ vertical = rec.vertical;
2029
+ }
2030
+ // A hosted scope (a vertical bound, on a non-cpLess host) has no local storage worth
2031
+ // reading — its grants live in the deployment serving it, reachable only through the
2032
+ // delegation. Without one configured, the ternary below would silently fall to THIS
2033
+ // host's own placeholder DO and answer an unrelated (and likely empty) position as
2034
+ // if it were the scope's own — a fail-open wrong answer, not a fail-closed refusal.
2035
+ if (!this.cpLess && vertical !== null && !this.systemSwitchDelegation) {
2036
+ throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot read its schedule switches`);
2037
+ }
2038
+ 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());
2042
+ const offModules = new Set(states.filter((s) => s.schedules === 'off').map((s) => s.moduleId));
2043
+ const explanations = offModules.size > 0
2044
+ ? await lastSwitchedOff(tenantId, scopeId, offModules)
2045
+ : new Map();
2046
+ const result = states.map((s) => ({
2047
+ moduleId: s.moduleId,
2048
+ schedules: s.schedules,
2049
+ switchedOff: explanations.get(s.moduleId) ?? null,
2050
+ }));
2051
+ // K-24: reading the switch's position and any live incident reason is itself
2052
+ // access-logged, the same as every other HostAdmin read.
2053
+ await this.recordAccess(actor, 'systemGrantsStatus', { tenantId, scopeId }, null, result.length);
2054
+ return result;
2055
+ };
2056
+ /**
2057
+ * The peer switch's status read (#1706) — `systemGrantsStatusOf` with the subject
2058
+ * swapped, and delegated for the same reason the write is: a hosted scope's
2059
+ * `vertical:<slug>` grants live in the deployment serving it, and this host's own
2060
+ * namespace holds a placeholder no peer call ever consults. Reading the placeholder
2061
+ * would be confidently wrong rather than loudly unavailable, which on a kill switch's
2062
+ * status is the worse of the two, so a hosted scope with no delegation configured is
2063
+ * refused. The admin-log join happens HERE only — the deployment holds no admin log.
2064
+ */
2065
+ const peerGrantsStatusOf = async (actor, node) => {
2066
+ const { tenantId, scopeId } = node;
2067
+ let vertical = null;
2068
+ if (!this.cpLess) {
2069
+ const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2070
+ if (!rec)
2071
+ throw substratError('not_found', `unknown scope for tenant: (${tenantId}, ${scopeId})`);
2072
+ vertical = rec.vertical;
2073
+ }
2074
+ if (!this.cpLess && vertical !== null && !this.peerSwitchDelegation) {
2075
+ throw substratError('unavailable', `no delegation configured for hosted scope ${scopeId} (vertical '${vertical}') — cannot read which ` +
2076
+ `peers may call it`);
2077
+ }
2078
+ const delegation = this.cpLess || vertical !== null ? this.peerSwitchDelegation : undefined;
2079
+ const states = delegation
2080
+ ? await delegation.status({ tenantId, scopeId })
2081
+ : peerGrantsEntry.array().parse(await this.scopeStub(scopeId).peerGrantsStatus());
2082
+ const offPeers = new Set(states.filter((p) => p.calls === 'off').map((p) => p.vertical));
2083
+ const explanations = offPeers.size > 0
2084
+ ? await lastSwitchedOffBy(tenantId, scopeId, 'revokeFromPeer', 'vertical', offPeers)
2085
+ : new Map();
2086
+ const result = states.map((p) => ({
2087
+ vertical: p.vertical,
2088
+ calls: p.calls,
2089
+ switchedOff: explanations.get(p.vertical) ?? null,
2090
+ }));
2091
+ await this.recordAccess(actor, 'peerGrantsStatus', { tenantId, scopeId }, null, result.length);
2092
+ return result;
2093
+ };
1726
2094
  return {
1727
2095
  // #603: fixed at construction — a worker deployed without SECRET_BOX_KEY can never
1728
2096
  // store a credential, and saying so is what lets a transport answer 503 instead of 500.
@@ -1879,8 +2247,14 @@ export class CloudflareScopeHost {
1879
2247
  // #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
1880
2248
  // whole rule, shared with the pure adapter; this is the directory check, the reach
1881
2249
  // into the scope's storage, and the audit row around it.
2250
+ peerGrantsStatus: peerGrantsStatusOf,
2251
+ revokeFromPeer: async (actor, raw) => switchPeerAt(actor, raw, 'off'),
2252
+ restoreToPeer: async (actor, raw) => switchPeerAt(actor, raw, 'on'),
1882
2253
  revokeFromSystem: async (actor, raw) => switchSystem(actor, raw, 'off'),
1883
2254
  restoreToSystem: async (actor, raw) => switchSystem(actor, raw, 'on'),
2255
+ // #1674: the switch's status read — same gate, same delegation, and (unlike the
2256
+ // deployment it may delegate to) the admin log to explain an `off` entry.
2257
+ systemGrantsStatus: systemGrantsStatusOf,
1884
2258
  // #1672 — the platform's two capability verbs. Audited AFTER the write, on both, and
1885
2259
  // the failure each leaves is the safe one: a mint whose audit row did not land never
1886
2260
  // returned its secret, so nobody can ever exchange it; a revoke whose row did not land
@@ -2014,6 +2388,9 @@ export class CloudflareScopeHost {
2014
2388
  await this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
2015
2389
  return rows.map(mapHostname);
2016
2390
  },
2391
+ // #1706: the directory's answer to "vertical Y in tenant T", in the ControlPlaneDO, by
2392
+ // the kernel's one rule. No actor, not logged — `resolveHostname`'s machine-path reason.
2393
+ resolveVerticalInstance: async (tenantId, vertical) => verticalResolution.parse(await this.cp.resolveVerticalInstance(tenantId, verticalSlug.parse(vertical))),
2017
2394
  resolveHostname: async (raw) =>
2018
2395
  // The router's per-request read. No actor, not logged — the same machine-path
2019
2396
  // carve-out resolveIdentity has (K-24). Shares its mapping with the router's
@@ -2589,6 +2966,35 @@ export class CloudflareScopeHost {
2589
2966
  await this.recordAccess(actor, 'listScopeTables', { tenantId, scopeId }, null, tables.length);
2590
2967
  return tables;
2591
2968
  },
2969
+ readExportedEvents: async (actor, tenantId, scopeId, raw) => {
2970
+ // #1705: the producer half. The DO decides what leaves, from its own registered
2971
+ // exports. This side gates the pair (K-3), parses both directions, and writes the
2972
+ // access row, because a platform read of domain data leaving a scope is one.
2973
+ const input = exportReadInput.parse(raw);
2974
+ // The read's own K-3 and reap gate, then the same refusal the door gives: on the
2975
+ // shared control plane this scope's outbox lives in its vertical's deployment, and
2976
+ // the placeholder here would report a hosted producer as having nothing to export.
2977
+ // Not `peerScopeGate`: a read must not migrate, and the phase makes this call once
2978
+ // per edge per pass, where an extra round trip is a fleet-wide cost.
2979
+ this.assertServedHere(await this.scopeRecordForRead(tenantId, scopeId), scopeId, 'readExportedEvents');
2980
+ const batch = exportedBatch.parse(await this.scopeStub(scopeId).exportedEventsRead(input, tenantId, scopeId));
2981
+ await this.recordAccess(actor, 'readExportedEvents', { tenantId, scopeId }, { consumer: input.consumer, after: input.after, limit: input.limit }, batch.events.length);
2982
+ return batch;
2983
+ },
2984
+ importState: async (actor, tenantId, scopeId) => {
2985
+ // Answered with no call at all when this deployment imports nothing: not the directory,
2986
+ // not the DO. "Imports nothing" is a fact about this code, and it names no scope, so it
2987
+ // tells a caller nothing about a (tenant, scope) pair it may not address.
2988
+ if (this.crossVertical.consumes().length === 0)
2989
+ return { consumes: [], cursors: [] };
2990
+ // Then the read's own gate, and the same refusal: a hosted scope's watermark lives in
2991
+ // its vertical's deployment, and the placeholder here would answer "never read
2992
+ // anything", which a pass would act on by re-delivering that edge from the start.
2993
+ this.assertServedHere(await this.scopeRecordForRead(tenantId, scopeId), scopeId, 'importState');
2994
+ const state = importState.parse(await this.scopeStub(scopeId).importStateRead());
2995
+ await this.recordAccess(actor, 'importState', { tenantId, scopeId }, null, state.cursors.length);
2996
+ return state;
2997
+ },
2592
2998
  readUndrainedEvents: async (actor, tenantId, scopeId, limit) => {
2593
2999
  const record = await this.scopeRecordForRead(tenantId, scopeId);
2594
3000
  const bounded = Math.min(Math.max(limit ?? 200, 1), 1000);
@@ -2918,13 +3324,23 @@ export class CloudflareScopeHost {
2918
3324
  `_substrat_platform_requests — erasing now would destroy the subject key while ` +
2919
3325
  `leaving their payloads in the intent journal. Redeploy the vertical and re-run.`);
2920
3326
  }
2921
- const { events: eventsRedacted, intents: intentsRedacted } = redacted;
3327
+ // A DO from after #1600 and before #1632: it redacted the outbox and the intent
3328
+ // journal and never looked at the job-run tables. Refused BEFORE the key, for the
3329
+ // reason above — its reply read as `jobRuns: 0` would receipt an erasure that left
3330
+ // the person in a step's memo.
3331
+ if (!('jobRuns' in redacted) || typeof redacted.jobRuns !== 'number') {
3332
+ throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO from before #1632, whose redaction does not reach ` +
3333
+ `_substrat_job_runs or _substrat_job_steps — erasing now would destroy the subject ` +
3334
+ `key while leaving their data in the job-run tables. Redeploy the vertical and re-run.`);
3335
+ }
3336
+ const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted } = redacted;
2922
3337
  const at = new Date().toISOString();
2923
3338
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
2924
3339
  const receipt = subjectShredReceipt.parse({
2925
3340
  subjectId,
2926
3341
  eventsRedacted,
2927
3342
  intentsRedacted,
3343
+ jobRunsRedacted,
2928
3344
  keyDestroyed: existed,
2929
3345
  tombstoned: true,
2930
3346
  });
@@ -2934,7 +3350,7 @@ export class CloudflareScopeHost {
2934
3350
  await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
2935
3351
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
2936
3352
  // an intent payload is a whole event's worth of it.
2937
- await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted);
3353
+ await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted);
2938
3354
  return receipt;
2939
3355
  },
2940
3356
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -3213,7 +3629,11 @@ export class CloudflareScopeHost {
3213
3629
  };
3214
3630
  },
3215
3631
  recordConnectionUse: async (id, outcome) => {
3216
- await this.cp.recordConnectionUse(id, outcome.ok ? null : outcome.error, new Date().toISOString());
3632
+ const row = await this.cp.recordConnectionUse(id, outcome.ok ? null : outcome.error, new Date().toISOString());
3633
+ // #1691: the line is settled — now the data point, off the identity the DO read
3634
+ // from the row (never the caller's input), fire-and-forget.
3635
+ if (row)
3636
+ recordConnectorCall(this.connectorCalls, connectorCallRecord(row, outcome));
3217
3637
  },
3218
3638
  putConnectorState: async (id, key, value) => {
3219
3639
  // JSON on the coordinator; the DO stores an opaque string, the same
@@ -3946,6 +4366,14 @@ export class CloudflareScopeHost {
3946
4366
  expires_at: null,
3947
4367
  }));
3948
4368
  }),
4369
+ // #1706: each declared PEER's grants ride the same unit — the CP-less mirror of
4370
+ // `provisionScope`'s peer seat, through the one function that decides it.
4371
+ ...peerSeats(collectPeers(this.peerSources), input.scopeId).map((seat) => ({
4372
+ subject: seat.subject,
4373
+ relation: seat.relation,
4374
+ object: seat.object,
4375
+ expires_at: null,
4376
+ })),
3949
4377
  // #592: the tenant's connection grants ride the same unit — the provision-time
3950
4378
  // mirror of `connectorGrantLocal`, so the connector return path works on every
3951
4379
  // install, not only the one that existed when `grantToConnection` ran.
@@ -4111,6 +4539,48 @@ export class CloudflareScopeHost {
4111
4539
  // plain strings become the published shape here rather than on trust.
4112
4540
  return systemSwitchOutcome.parse(await this.scopeStub(scopeId).switchSystemSchedules(moduleId, scopeId, to, new Date().toISOString()));
4113
4541
  }
4542
+ /**
4543
+ * The far end of the status read for a scope served HERE (#1674): `/internal/system-grants`
4544
+ * lands on this, from the shared control plane's `systemGrantsStatus`. No admin-log join
4545
+ * happens here — this deployment holds none — so it answers the bare per-module position
4546
+ * only, exactly like `systemSwitchLocal` answers a bare outcome; the platform joins its
4547
+ * own admin log onto this by moduleId once it returns.
4548
+ */
4549
+ async systemGrantsStatusLocal(scopeId) {
4550
+ return systemScheduleEntry.array().parse(await this.scopeStub(scopeId).systemGrantsStatus());
4551
+ }
4552
+ /**
4553
+ * The far end of the peer switch's status read (#1706): `/internal/peer-grants` lands on
4554
+ * this, from the shared control plane's `peerGrantsStatus`. The bare per-peer position
4555
+ * only — who switched a peer off and why is the admin log's, and this deployment holds
4556
+ * none — so the platform joins its own log onto this by slug once it returns.
4557
+ */
4558
+ async peerGrantsStatusLocal(scopeId) {
4559
+ return peerGrantsEntry.array().parse(await this.scopeStub(scopeId).peerGrantsStatus());
4560
+ }
4561
+ // -- the peer door's far end (#1706) ----------------------------------------
4562
+ // The platform identified the calling deployment (the router, at the hop the caller cannot
4563
+ // forge) and resolved THIS scope as the instance of this vertical in the caller's tenant;
4564
+ // it reaches here over the platform-secret-gated `/internal/vertical-invoke`. What runs
4565
+ // HERE is the half only this deployment can enforce: the peer's admission and its grants,
4566
+ // in the scope's own DO, on every call.
4567
+ /** Invoke ONE operation in this deployment as a PEER vertical (#1706). */
4568
+ async verticalInvokeLocal(caller, tenantId, scopeId, operation, input, options) {
4569
+ return (await this.getVerticalScope(caller, tenantId, scopeId)).invoke(operation, input, options);
4570
+ }
4571
+ /**
4572
+ * Move one peer's kill switch on a scope served HERE (#1706) — the far end of
4573
+ * `revokeFromPeer` / `restoreToPeer` for a hosted scope. Nothing is audited here; the
4574
+ * platform writes the admin rows around this call, as for the schedule switch.
4575
+ */
4576
+ async peerSwitchLocal(scopeId, vertical, to) {
4577
+ return peerSwitchOutcome.parse(await this.scopeStub(scopeId).switchPeer(verticalSlug.parse(vertical), scopeId, to, new Date().toISOString()));
4578
+ }
4579
+ }
4580
+ /** SHA-256 hex of an attachment's bytes — what `AttachmentRecord.sha256` holds. */
4581
+ async function sha256Hex(body) {
4582
+ const digest = await crypto.subtle.digest('SHA-256', body);
4583
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
4114
4584
  }
4115
4585
  /** A ledger row -> the wire entry, the attribution re-nested (#1054). */
4116
4586
  function modelUsageEntryOf(r) {