@substrat-run/adapter-cloudflare 0.159.0 → 0.161.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,16 +1,16 @@
1
1
  import { env as ambientEnv } from 'cloudflare:workers';
2
2
  import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
3
3
  import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
4
- import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, attachmentRecord, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
4
+ import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
5
5
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
6
- import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
6
+ import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, bytesStream, isSizedByteStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, fetchLikeOf, deadlineFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleRefusal, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
7
7
  import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
8
- import { COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
8
+ import { assertWireAuthFilter, wireAuthAccessTarget, COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
9
9
  import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf } from '@substrat-run/kernel';
10
10
  import { PLATFORM_BINDING_PREFIX, scopeStoreBindingName, scopeStoreObjectName, STORE_CALL_URL, StoreError } from '@substrat-run/contracts';
11
11
  import { isOrangeToOrange, isUpgradeRequest, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, liveInstant, encodeLiveWithin, liveRefusalStatus, liveWithinOf, } from './live-reads.js';
12
12
  import { tenantStoreDatabaseName } from './d1.js';
13
- import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
13
+ import { blobStoreBucketName, r2TenantBlobStore, workerdSha256 } from './r2.js';
14
14
  import { unwrapReply } from './do-reply.js';
15
15
  /** DO row → contract shape. Never reads the secrets table — that is the split. */
16
16
  const toConnection = (r) => connection.parse({
@@ -207,6 +207,28 @@ async function compensatingRefusal(store, key, call) {
207
207
  await store.delete(key).catch(() => { });
208
208
  return reply;
209
209
  }
210
+ /**
211
+ * Ask the scope's write gate before a streamed body is read (#2516). A scope DO from before the
212
+ * verb exists cannot answer it, and a missing answer is never a pass: the call fails closed with a
213
+ * retryable `unavailable` that says to redeploy, rather than skipping the gate and reading the body.
214
+ */
215
+ export async function askAttachmentWriteGate(stub, entity, principal, tenantId, scopeId, connectionId) {
216
+ let reply;
217
+ try {
218
+ reply = await stub.attachmentCheckWriteReply(entity, principal, tenantId, scopeId, connectionId);
219
+ }
220
+ catch (err) {
221
+ // By shape, not wording: the scope answers every refusal as data (`replyOf`), so what is
222
+ // THROWN here is a failure of the call itself, and a verb the receiver does not have is a
223
+ // `TypeError` however workerd phrases it. A dropped connection is a plain `Error`, rethrown.
224
+ if (err instanceof TypeError || err?.name === 'TypeError') {
225
+ throw substratError('unavailable', `the deployment serving scope ${scopeId} predates the upload write check (#2516): a streamed upload ` +
226
+ `is refused rather than read unchecked. Redeploy the vertical, then retry. Nothing was stored.`);
227
+ }
228
+ throw err;
229
+ }
230
+ unwrapReply(reply);
231
+ }
210
232
  /** A tenant-held module's OFF answered without `deniesTenantGrants` (#1823). */
211
233
  const undeniedMessage = (scopeId, moduleId) => `the deployment serving scope ${scopeId} predates the kill switch's tenant-grant denial (#1823): ` +
212
234
  `module '${moduleId}' holds a tenant-level grant that its OFF would leave authorizing there. ` +
@@ -327,6 +349,12 @@ const DOOR_WORDS = {
327
349
  };
328
350
  /** #2029: the rewind hold's key for one subject — a module's id, bare as #1819 stored it, or `vertical:<slug>`. */
329
351
  const holdKeyOf = (kind, key) => (kind === 'system' ? key : peerSubjectRef(key));
352
+ /** #2541, #2544: a vertical-side read's answer, or the refusal when the scope's own object said it holds nothing (`null`). */
353
+ function heldOrRefused(scopeId, answer) {
354
+ if (answer === null)
355
+ throw substratError('conflict', scopeNotHeldRefusal(scopeId));
356
+ return answer;
357
+ }
330
358
  export class CloudflareScopeHost {
331
359
  admin;
332
360
  scopeNs;
@@ -346,6 +374,8 @@ export class CloudflareScopeHost {
346
374
  moduleIds = new Set();
347
375
  /** #2430: what a registered module declares `holder: 'system'` — `assignRole`'s refusal reads it. */
348
376
  reservedKeys = new Set();
377
+ /** #2447: every key a registered or listed module declares, marked or not — a role definition reads it. */
378
+ declaredKeys = new Set();
349
379
  /** #2430: the declared permission surface `registerModule` holds each module to, when given. */
350
380
  permissions;
351
381
  /**
@@ -370,6 +400,7 @@ export class CloudflareScopeHost {
370
400
  /** Executor id → {eventType, handler} (K-22 §4.2). Coordinator-side, not in the DO. */
371
401
  secretBox;
372
402
  fetchImpl;
403
+ streamIdleMs;
373
404
  connectorCalls;
374
405
  /** The live D1 client for per-tenant stores (#301); undefined ⇒ refuse loudly. */
375
406
  tenantStores;
@@ -445,8 +476,10 @@ export class CloudflareScopeHost {
445
476
  this.servicePrincipals = options.servicePrincipals;
446
477
  this.permissions = options.permissions;
447
478
  // #2430: what the declared surface reserves holds here too, whatever is registered under its ids.
448
- for (const m of options.permissions?.modules ?? [])
479
+ for (const m of options.permissions?.modules ?? []) {
449
480
  noteReservedKeys(this.reservedKeys, m.manifest);
481
+ noteDeclaredKeys(this.declaredKeys, m.manifest);
482
+ }
450
483
  this.secretBox = options.secretBox ?? unconfiguredSecretBox;
451
484
  this.tenantStores = options.tenantStores;
452
485
  this.blobStores = options.blobStores;
@@ -455,7 +488,9 @@ export class CloudflareScopeHost {
455
488
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
456
489
  this.attachmentExtractors = options.attachmentExtractors ?? [];
457
490
  this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
458
- this.fetchImpl = options.fetch ?? globalFetch;
491
+ // #2498: whatever fetch is injected, a sized stream body reaches it converted (`fetchLikeOf`).
492
+ this.fetchImpl = fetchLikeOf(options.fetch ?? globalFetch);
493
+ this.streamIdleMs = options.streamIdleTimeoutMs;
459
494
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
460
495
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
461
496
  this.scopeNs = options.scope;
@@ -511,7 +546,8 @@ export class CloudflareScopeHost {
511
546
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
512
547
  const vertical = scope?.vertical ?? null;
513
548
  const admin = this.admin;
514
- const fetchImpl = this.fetchImpl;
549
+ // #2518: the call under `timeoutMs`, a streamed body under the idle window instead.
550
+ const fetchImpl = deadlineFetch(this.fetchImpl, timeoutMs, { idleMs: this.streamIdleMs });
515
551
  return {
516
552
  admin,
517
553
  tenantId,
@@ -542,7 +578,8 @@ export class CloudflareScopeHost {
542
578
  grants: async () => (await this.connectionGrantsInScope(tenantId, scopeId))
543
579
  .filter((g) => g.connectionId === open.id)
544
580
  .map((g) => g.permission),
545
- openAttachment: (attachmentId) => this.getConnectorAttachments(open.id, scopeId, { eventId }).then((a) => a.open(attachmentId)),
581
+ openAttachment: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).open(attachmentId),
582
+ openAttachmentStream: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).openStream(attachmentId),
546
583
  // #687: open a cell the scope sealed TO this connection. The keyId-indexed
547
584
  // map goes in, never out — a connector receives the plaintext of one cell,
548
585
  // not a private key it could mislay. Retired keys are in the map too, so a
@@ -552,10 +589,7 @@ export class CloudflareScopeHost {
552
589
  // #1691: timed where the call is made, so the data point carries a duration.
553
590
  const started = Date.now();
554
591
  try {
555
- const res = await fetchImpl(input, {
556
- ...init,
557
- signal: AbortSignal.timeout(timeoutMs),
558
- });
592
+ const res = await fetchImpl(input, init);
559
593
  await admin.recordConnectionUse(open.id, settleConnectionUse(provider, Date.now() - started, { response: res }));
560
594
  return res;
561
595
  }
@@ -972,14 +1006,29 @@ export class CloudflareScopeHost {
972
1006
  * gate (the caller is the control plane, which did the K-3 check + audit on its side).
973
1007
  */
974
1008
  async introspectScopeTables(scopeId) {
975
- return this.scopeStub(scopeId).introspectTables();
1009
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTablesIfHeld());
976
1010
  }
1011
+ /**
1012
+ * The Data view's table page (#2563): refused `conflict` with the shared sentence for a scope the
1013
+ * deployment does not hold, as the reads above are, never answered as an empty store. The carry
1014
+ * and its cleanup read a wiped copy's tombstone, which is not held by design: they use
1015
+ * `readTombstoneTable`.
1016
+ */
977
1017
  async introspectScopeTable(scopeId, input) {
1018
+ const reply = heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTableIfHeld(input.table, input.limit, input.offset));
1019
+ return unwrapReply(reply);
1020
+ }
1021
+ /**
1022
+ * A table of a store this deployment may no longer HOLD, for the carry and its cleanup, which ask
1023
+ * a wiped copy whether it carries the `carried_away` tombstone (#2563). Not guarded: a wiped copy
1024
+ * answers nothing else. Not for a screen or a console; those use `introspectScopeTable`.
1025
+ */
1026
+ async readTombstoneTable(scopeId, input) {
978
1027
  return unwrapReply(await this.scopeStub(scopeId).introspectTableReply(input.table, input.limit, input.offset));
979
1028
  }
980
- /** The SQL console's CP-less path (#219) — same trust line as the pair above. */
1029
+ /** The SQL console's CP-less path (#219): guarded as the table page is (#2563). */
981
1030
  async introspectScopeQuery(scopeId, input) {
982
- return unwrapReply(await this.scopeStub(scopeId).introspectQueryReply(input.sql));
1031
+ return unwrapReply(heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectQueryIfHeld(input.sql)));
983
1032
  }
984
1033
  /**
985
1034
  * The K-35 denial log's CP-less path (#867) — same trust line as the reads above.
@@ -1262,6 +1311,8 @@ export class CloudflareScopeHost {
1262
1311
  * safe; scope-level tuples (the restored grants) are never touched.
1263
1312
  */
1264
1313
  async projectRolesLocal(tenantId, scopeId, roles) {
1314
+ // #2447: the role set is defined here, so it is judged here, before anything is projected.
1315
+ await this.refuseReservedRoleSet(roles, tenantId, scopeId);
1265
1316
  unwrapReply(await this.scopeStub(scopeId).applyProjectionReply(tenantId, roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' })), []));
1266
1317
  }
1267
1318
  /**
@@ -1298,43 +1349,50 @@ export class CloudflareScopeHost {
1298
1349
  }
1299
1350
  /** Facet this host's own scope's outbox (#1239) — the vertical-host read. */
1300
1351
  async facetEventsLocal(scopeId, input) {
1301
- return this.scopeStub(scopeId).facetEvents(input);
1352
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).facetEventsIfHeld(input));
1302
1353
  }
1303
1354
  /** What one event set off (#1237) on this host's own scope — the vertical-host read. */
1304
1355
  async eventEffectsLocal(scopeId, input) {
1305
- return this.scopeStub(scopeId).eventEffects(input);
1356
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventEffectsIfHeld(input));
1306
1357
  }
1307
1358
  /** Everything one call emitted (#1237) on this host's own scope — the vertical-host read. */
1308
1359
  async invocationEventsLocal(scopeId, input) {
1309
- return this.scopeStub(scopeId).invocationEvents(input);
1360
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).invocationEventsIfHeld(input));
1310
1361
  }
1311
1362
  /** Every delivery that gave up (#1525) on this host's own scope — the vertical-host read. */
1312
1363
  async deadLettersLocal(scopeId, input) {
1313
- return this.scopeStub(scopeId).deadLetters(input);
1364
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).deadLettersIfHeld(input));
1314
1365
  }
1315
1366
  /** One entity's lifecycle replayed (#1744) on this host's own scope — the vertical-host read. */
1316
1367
  async lifecycleFlowLocal(scopeId, input) {
1317
- return this.scopeStub(scopeId).lifecycleFlow(input);
1368
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).lifecycleFlowIfHeld(input));
1318
1369
  }
1319
1370
  /** Business volumes per bucket (#1750) on this host's own scope — the vertical-host read. */
1320
1371
  async operationSeriesLocal(scopeId, input) {
1321
- return this.scopeStub(scopeId).operationSeries(input);
1372
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).operationSeriesIfHeld(input));
1322
1373
  }
1323
1374
  /** One event's causal chain (#1237) on this host's own scope — the vertical-host read. */
1324
1375
  async eventCauseLocal(scopeId, input) {
1325
- return this.scopeStub(scopeId).eventCause(input);
1376
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventCauseIfHeld(input));
1326
1377
  }
1327
1378
  /** One record's event history (#1235) on this host's own scope — the vertical-host read. */
1328
1379
  async entityHistoryLocal(scopeId, input) {
1329
- return this.scopeStub(scopeId).entityHistory(input);
1380
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).entityHistoryIfHeld(input));
1330
1381
  }
1331
1382
  /** When this host's own scope applied each migration (#1236) — the vertical-host read. */
1332
1383
  async appliedMigrationsLocal(scopeId) {
1333
- return this.scopeStub(scopeId).appliedMigrations();
1384
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).appliedMigrationsIfHeld());
1334
1385
  }
1335
- /** This host's own scope's database size in bytes (#1524), the vertical-host read. */
1386
+ /**
1387
+ * This host's own scope's database size in bytes (#1524), the vertical-host read. A scope this
1388
+ * deployment does not hold is refused (#2541), not read as 0.
1389
+ */
1336
1390
  async databaseSizeLocal(scopeId) {
1337
- return this.scopeStub(scopeId).databaseSize();
1391
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).databaseSizeIfHeld());
1392
+ }
1393
+ /** This host's own scope's attachment bytes (#2501), the vertical-host read; refused as the size is. */
1394
+ async attachmentSizeLocal(scopeId) {
1395
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).attachmentBytesIfHeld());
1338
1396
  }
1339
1397
  /**
1340
1398
  * Each scope store's size in this deployment (#2207). The control plane already
@@ -1376,7 +1434,8 @@ export class CloudflareScopeHost {
1376
1434
  * so a caller cannot ask this side for more than the other would.
1377
1435
  */
1378
1436
  async undrainedEventsLocal(scopeId, limit) {
1379
- return undrainedEventsOf(await this.scopeStub(scopeId).undrainedEventsRead(Math.min(Math.max(limit, 1), 1000)));
1437
+ const bounded = Math.min(Math.max(limit, 1), 1000);
1438
+ return undrainedEventsOf(heldOrRefused(scopeId, await this.scopeStub(scopeId).undrainedEventsReadIfHeld(bounded)));
1380
1439
  }
1381
1440
  /**
1382
1441
  * Stamp `drained_at` on this host's own scope (#1334) — the delegation's other half.
@@ -1402,7 +1461,7 @@ export class CloudflareScopeHost {
1402
1461
  * platform's `redrainEvents` is the door, and a count egresses nothing to record.
1403
1462
  */
1404
1463
  async redrainCountLocal(scopeId, drainedBefore) {
1405
- return this.scopeStub(scopeId).redrainCount(drainedBefore);
1464
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).redrainCountIfHeld(drainedBefore));
1406
1465
  }
1407
1466
  /**
1408
1467
  * The PITR bookmarks one scope recorded before its migration passes (#286) — the
@@ -1410,7 +1469,7 @@ export class CloudflareScopeHost {
1410
1469
  * `/internal/bookmarks`; the control plane is the gate and the auditor.
1411
1470
  */
1412
1471
  async migrationBookmarksLocal(scopeId) {
1413
- return this.scopeStub(scopeId).migrationBookmarks();
1472
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).migrationBookmarksIfHeld());
1414
1473
  }
1415
1474
  /**
1416
1475
  * Rewind one scope to a pre-migration bookmark (#286's backout) — schema AND data,
@@ -1583,6 +1642,7 @@ export class CloudflareScopeHost {
1583
1642
  this.crossVertical.register(manifest, registration.imports);
1584
1643
  this.moduleIds.add(manifest.id);
1585
1644
  noteReservedKeys(this.reservedKeys, manifest);
1645
+ noteDeclaredKeys(this.declaredKeys, manifest);
1586
1646
  if (manifest.schedules && manifest.schedules.length > 0) {
1587
1647
  this.moduleSchedules.set(manifest.id, manifest.schedules);
1588
1648
  }
@@ -1844,22 +1904,11 @@ export class CloudflareScopeHost {
1844
1904
  const filename = view.filename ?? input.filename;
1845
1905
  // Recorded as it is judged: the essence, never a parameter or a stray space.
1846
1906
  const contentType = normalizeUploadMediaType(input.contentType);
1847
- const record = attachmentRecord.parse({
1848
- id,
1849
- entity: view.entity,
1850
- filename,
1851
- contentType,
1852
- size: input.body.byteLength,
1853
- sha256: await attachmentSha256(input.body),
1854
- visibility: view.visibility,
1855
- createdBy: mintedBy,
1856
- createdAt: new Date().toISOString(),
1857
- });
1858
1907
  // Bytes first, row second, exactly as an upload: a refusal (size, type, a lost key, a link
1859
1908
  // someone else spent first) compensates the object away, and the link is unspent with it.
1860
1909
  // A lost reply keeps the bytes (`compensatingRefusal`), and a retry carrying the same
1861
1910
  // `Idempotency-Key` is answered as a replay (#2315).
1862
- await store.put(key, input.body, { contentType });
1911
+ const record = await putAttachmentBytes(store, key, { id, entity: view.entity, filename, contentType, visibility: view.visibility, createdBy: mintedBy, createdAt: new Date().toISOString() }, input.body, workerdSha256);
1863
1912
  return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentRedeemUploadLinkReply(record, secret, tenantId, scopeId, input.idempotencyKey)));
1864
1913
  });
1865
1914
  }
@@ -1908,19 +1957,28 @@ export class CloudflareScopeHost {
1908
1957
  throw new Error(`connector attachment ${verb} is not delegated (#574) — upload and open are the ` +
1909
1958
  `verbs that cross the /internal seam to the serving deployment`);
1910
1959
  };
1960
+ const openArgs = (attachmentId) => ({
1961
+ connectionId,
1962
+ tenantId: tenant,
1963
+ scopeId,
1964
+ vertical,
1965
+ attachmentId,
1966
+ // #726 remedy B: the delivery, resolved at the far end against the
1967
+ // deployment's own outbox. Absent outside a dispatch, where the ordinary
1968
+ // permission check still applies.
1969
+ eventId: forEvent?.eventId,
1970
+ });
1911
1971
  return {
1912
1972
  upload: (upload) => delegation.uploadAttachment({ connectionId, tenantId: tenant, scopeId, vertical, upload }),
1913
- open: (attachmentId) => delegation.openAttachment({
1914
- connectionId,
1915
- tenantId: tenant,
1916
- scopeId,
1917
- vertical,
1918
- attachmentId,
1919
- // #726 remedy B: the delivery, resolved at the far end against the
1920
- // deployment's own outbox. Absent outside a dispatch, where the ordinary
1921
- // permission check still applies.
1922
- eventId: forEvent?.eventId,
1923
- }),
1973
+ open: (attachmentId) => delegation.openAttachment(openArgs(attachmentId)),
1974
+ // #2514: streamed over the seam when the embedder can. One that cannot reads the bytes whole,
1975
+ // as `open` does, and hands them on as a stream, so the verb still works and holds what it did.
1976
+ openStream: async (attachmentId) => {
1977
+ if (delegation.openAttachmentStream)
1978
+ return delegation.openAttachmentStream(openArgs(attachmentId));
1979
+ const opened = await delegation.openAttachment(openArgs(attachmentId));
1980
+ return opened && { record: opened.record, body: bytesStream(opened.body), contentType: opened.contentType };
1981
+ },
1924
1982
  list: notDelegated('list'),
1925
1983
  remove: notDelegated('remove'),
1926
1984
  // #1575: undelegated for `list`'s reason — a connector picks a document by id.
@@ -1967,6 +2025,10 @@ export class CloudflareScopeHost {
1967
2025
  const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
1968
2026
  return record ? this.openAttachmentBytes(store, scopeId, record) : null;
1969
2027
  },
2028
+ openStream: async (attachmentId) => {
2029
+ const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
2030
+ return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
2031
+ },
1970
2032
  remove: async (attachmentId) => unwrapReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
1971
2033
  attachmentId,
1972
2034
  })),
@@ -1986,31 +2048,42 @@ export class CloudflareScopeHost {
1986
2048
  const asPrincipalId = ('principal' in subject
1987
2049
  ? subject.principal
1988
2050
  : subject.connectionId);
2051
+ // The read gate, asked in the DO before the blob store is touched: a refused read never reaches it.
2052
+ const authorizeRead = async (attachmentId) => unwrapReply(await stub.attachmentAuthorizeReply(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId));
1989
2053
  return {
1990
2054
  upload: async (input) => {
1991
2055
  const id = ulid();
1992
2056
  const key = attachmentBlobKey(scopeId, id);
1993
- const record = attachmentRecord.parse({
2057
+ const fields = {
1994
2058
  id,
1995
2059
  entity: input.entity,
1996
2060
  filename: input.filename,
1997
2061
  contentType: input.contentType,
1998
- size: input.body.byteLength,
1999
- sha256: await attachmentSha256(input.body),
2000
2062
  visibility: input.visibility,
2001
2063
  createdBy,
2002
2064
  createdAt: new Date().toISOString(),
2003
- });
2065
+ };
2066
+ // A streamed body is the case where bytes are the cost, so the write gate is asked before
2067
+ // the first one is read (#2516). The row's own check below stays: this one is only early.
2068
+ if (isSizedByteStream(input.body)) {
2069
+ await askAttachmentWriteGate(stub, input.entity, asPrincipalId, tenantId, scopeId, connectionId);
2070
+ }
2004
2071
  // Bytes first, row second — a crash between the two leaves an orphaned object (#2314);
2005
2072
  // a refused gate compensates, and only an answered refusal does (`compensatingRefusal`).
2006
- await store.put(key, input.body, { contentType: input.contentType });
2073
+ // A streamed body is hashed and counted as R2 reads it (#2498).
2074
+ const record = await putAttachmentBytes(store, key, fields, input.body, workerdSha256);
2007
2075
  return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentAddReply(record, asPrincipalId, tenantId, scopeId, connectionId)));
2008
2076
  },
2009
2077
  list: async (entity) => unwrapReply(await stub.attachmentListReply(entity, asPrincipalId, tenantId, scopeId, connectionId)),
2010
2078
  open: async (attachmentId) => {
2011
- const record = unwrapReply(await stub.attachmentAuthorizeReply(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId));
2079
+ const record = await authorizeRead(attachmentId);
2012
2080
  return record ? this.openAttachmentBytes(store, scopeId, record) : null;
2013
2081
  },
2082
+ // #2514: the same gate, then the bytes as R2 reads them, checked against the record as they pass.
2083
+ openStream: async (attachmentId) => {
2084
+ const record = await authorizeRead(attachmentId);
2085
+ return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
2086
+ },
2014
2087
  remove: async (attachmentId) => {
2015
2088
  const removed = unwrapReply(await stub.attachmentRemoveReply(attachmentId, asPrincipalId, tenantId, scopeId, connectionId));
2016
2089
  if (removed)
@@ -2044,7 +2117,17 @@ export class CloudflareScopeHost {
2044
2117
  return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
2045
2118
  }
2046
2119
  async importScope(actor, input, dump) {
2120
+ await this.importScopeFrom(actor, input, dump, true);
2121
+ }
2122
+ /**
2123
+ * `importScope`, with its role judgement (#2555) when `judge`, made before the scope is created so a
2124
+ * refused import leaves nothing behind. A snapshot passes false: it is the platform's copy of a live
2125
+ * scope of this host, made at once, so its seats are the ones that scope holds now (K-55).
2126
+ */
2127
+ async importScopeFrom(actor, input, dump, judge) {
2047
2128
  this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
2129
+ if (judge)
2130
+ await this.refuseDumpRoles(input.tenantId, null, dump);
2048
2131
  // Create the destination scope (directory row + DO + lazy migrate); the DO then
2049
2132
  // replaces its provisioned schema with the dump wholesale (drop-then-replay), so
2050
2133
  // the end state is the dump, at the source's frontier. Provenance is stamped from
@@ -2069,6 +2152,8 @@ export class CloudflareScopeHost {
2069
2152
  if (!existing)
2070
2153
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2071
2154
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2155
+ // #2555: judged before the fence, so a refused restore moves nothing.
2156
+ await this.refuseDumpRoles(tenantId, scopeId, dump);
2072
2157
  // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2073
2158
  await this.fenceOrphanSweep(scopeId, tenantId);
2074
2159
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
@@ -2135,15 +2220,16 @@ export class CloudflareScopeHost {
2135
2220
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2136
2221
  const dump = await this.admin.exportScope(actor, tenantId, scopeId);
2137
2222
  const snapshotId = ulid();
2138
- await this.importScope(actor, {
2223
+ await this.importScopeFrom(actor, {
2139
2224
  tenantId,
2140
2225
  scopeId: snapshotId,
2141
2226
  kind: opts?.kind ?? 'archive',
2142
2227
  vertical: source.vertical,
2228
+ verticalSource: 'copy',
2143
2229
  jurisdiction: source.jurisdiction,
2144
2230
  expiresAt: opts?.expiresAt,
2145
2231
  // forkedFrom/forkedAt are stamped from the dump by importScope.
2146
- }, dump);
2232
+ }, dump, false);
2147
2233
  // Bind the snapshot to the source's current version so it is a runnable copy at the
2148
2234
  // same frontier (a fresh bind, so it never re-triggers the snapshot path).
2149
2235
  if (source.verticalVersionId) {
@@ -3126,7 +3212,9 @@ export class CloudflareScopeHost {
3126
3212
  }
3127
3213
  return report;
3128
3214
  }
3129
- async runDueSchedules(moduleId, tenantId, scopeId) {
3215
+ async runDueSchedules(moduleId, tenantId, scopeId,
3216
+ /** #2517: what the sweep tick's earlier calls did to this scope (`ScopeHost.runDueSchedules`). */
3217
+ tick) {
3130
3218
  const report = { fired: 0, skipped: 0, failed: 0, errors: [], runs: [] };
3131
3219
  const schedules = this.moduleSchedules.get(moduleId);
3132
3220
  if (!schedules || schedules.length === 0)
@@ -3181,6 +3269,10 @@ export class CloudflareScopeHost {
3181
3269
  // scope at once. `ScheduleRunReport.platformRequests` says what that does and does not count.
3182
3270
  let platformRequests = 0;
3183
3271
  const counted = { onPlatformRequests: (n) => (platformRequests += n) };
3272
+ // #2517: the part of that the purge runs enqueued, which is what defers a later purge head the
3273
+ // cap refuses (`runPurgePass`): this call's, on top of what the tick's earlier calls report.
3274
+ const earlierPurgeRequests = tick?.earlierPurgeRequests ?? 0;
3275
+ let purgeRequests = 0;
3184
3276
  for (const schedule of schedules) {
3185
3277
  const last = await stub.scheduleLastRun(schedule.operation);
3186
3278
  const lastRun = last ? Date.parse(last) : null;
@@ -3215,12 +3307,24 @@ export class CloudflareScopeHost {
3215
3307
  // call and transaction — and the SCOPE decides which are due and runs them, so purge
3216
3308
  // authority is never an argument any stub can pass (`runPurgeSweep`).
3217
3309
  // #2192: under the run's own id, so the purges and their deliveries join this run's line.
3218
- // #2431: the purges' own requests run inside the scope, which reports no count for them;
3219
- // the deliveries their events route to the platform are counted below, as an invoke's are.
3220
- const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId));
3310
+ // #2431, #2440: the scope reports the requests its purges enqueued (`pass.platformRequests`),
3311
+ // and the deliveries their events route to the platform are counted below, as an invoke's are.
3312
+ // #2517: with what the purge runs before this one in the tick enqueued, so a head the cap
3313
+ // refuses because they filled the queue is deferred to the next tick rather than failed.
3314
+ const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId, earlierPurgeRequests + purgeRequests));
3315
+ if (pass.deferred) {
3316
+ // Ran nothing: no line, no cadence row, `skipped` like any run that did not happen.
3317
+ report.skipped += 1;
3318
+ report.deferred = (report.deferred ?? 0) + 1;
3319
+ report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
3320
+ continue;
3321
+ }
3322
+ platformRequests += pass.platformRequests ?? 0;
3323
+ purgeRequests += pass.platformRequests ?? 0;
3221
3324
  // The purges' events, delivered the way each invoke's own tail delivers them.
3222
3325
  const delivered = await this.drainExecutors(tenantId, scopeId, invocationId);
3223
3326
  platformRequests += delivered.routedToPlatform ?? 0;
3327
+ purgeRequests += delivered.routedToPlatform ?? 0;
3224
3328
  stillDue = purgeStillDue(pass);
3225
3329
  const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
3226
3330
  report.errors.push(...outcome.errors);
@@ -3274,6 +3378,8 @@ export class CloudflareScopeHost {
3274
3378
  lines.end();
3275
3379
  if (platformRequests > 0)
3276
3380
  report.platformRequests = platformRequests;
3381
+ if (purgeRequests > 0)
3382
+ report.purgeRequests = purgeRequests;
3277
3383
  return report;
3278
3384
  }
3279
3385
  /** The stub body, shared by the principal, connection, system and impersonation doors. */
@@ -3594,6 +3700,19 @@ export class CloudflareScopeHost {
3594
3700
  archivedFromStatus: (r.archived_from_status ?? null),
3595
3701
  createdAt: r.created_at,
3596
3702
  });
3703
+ // The directory rows `listScopes` and `listScopesReadable` map, filtered and paged alike.
3704
+ const scopeRows = (filter) => this.cp.listScopes({
3705
+ tenantId: filter?.tenantId,
3706
+ status: filter?.status
3707
+ ? Array.isArray(filter.status)
3708
+ ? filter.status
3709
+ : [filter.status]
3710
+ : undefined,
3711
+ vertical: filter?.vertical,
3712
+ limit: filter?.limit,
3713
+ cursor: filter?.cursor,
3714
+ order: filter?.order,
3715
+ });
3597
3716
  // The (version, scope) pair a bind and its impact read both start from, and the refusals
3598
3717
  // that come before any export-break question, in the order the bind makes them (#1756).
3599
3718
  const bindTarget = async (tenantId, scopeId, versionId) => {
@@ -4166,7 +4285,19 @@ export class CloudflareScopeHost {
4166
4285
  canStoreSecrets: isSecretBoxConfigured(this.secretBox),
4167
4286
  defineRole: async (actor, tenantId, role) => {
4168
4287
  const parsed = roleDefinition.parse(role);
4169
- const before = await this.cp.defineRole(tenantId, parsed);
4288
+ // #2447: judged in the ControlPlaneDO's unit, against this host's keys and the tenant's other
4289
+ // roles, so a role assigned through a door cannot be widened afterwards. A refusal writes
4290
+ // nothing, so a seat of the role as it was stored keeps working.
4291
+ const context = await this.definitionContext(tenantId, parsed);
4292
+ if (context.unreadable)
4293
+ throw substratError('forbidden', `role '${parsed.key}' cannot be judged: ${context.unreadable}`);
4294
+ // `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
4295
+ // before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
4296
+ // than being misread.
4297
+ const defined = await this.cp.defineRoleOrRefusal(tenantId, parsed, context.reservation, context.seatedInScopes);
4298
+ if ('refused' in defined)
4299
+ throw substratError('forbidden', defined.refused);
4300
+ const before = defined.before;
4170
4301
  await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
4171
4302
  await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
4172
4303
  },
@@ -4607,8 +4738,7 @@ export class CloudflareScopeHost {
4607
4738
  }
4608
4739
  throw substratError('conflict', `vertical '${parsed.slug}' is already registered as ${existing.source}`);
4609
4740
  }
4610
- await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, new Date().toISOString());
4611
- await this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
4741
+ await this.cp.insertVertical(parsed.slug, parsed.name, parsed.source, parsed.ownerTenant, envSpecJson, installSpecJson, parsed.listed ? 1 : 0, verticalOwnerNow(), this.adminEntry(actor, 'registerVertical', { tenantId: null }, null, parsed));
4612
4742
  },
4613
4743
  listVerticals: async (actor, page) => {
4614
4744
  const rows = await this.cp.listVerticals(page);
@@ -4624,7 +4754,9 @@ export class CloudflareScopeHost {
4624
4754
  // Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
4625
4755
  // (tenant-owned, not listed), whose blast radius is its own tenant: there the
4626
4756
  // sandbox contract is the gate and the version self-admits, noted so the
4627
- // publish seam can tell a staff vouch from this shortcut.
4757
+ // publish seam can tell a staff vouch from this shortcut. The DO judges it again on the
4758
+ // vertical as the insert's unit reads it, and that is what the row and the version carry
4759
+ // (#2528); this read answers an older DO, which takes what it is sent.
4628
4760
  const selfAdmits = owning.owner_tenant !== null && !owning.listed;
4629
4761
  // The manifest is retained for the serving upload (#286), not audited — a whole
4630
4762
  // manifest per publish would drown the admin log in bundle metadata.
@@ -4645,14 +4777,13 @@ export class CloudflareScopeHost {
4645
4777
  admission: selfAdmits ? 'admitted' : 'pending',
4646
4778
  admissionNote: selfAdmits ? AUTO_ADMISSION_NOTE : null,
4647
4779
  createdAt: new Date().toISOString(),
4648
- });
4649
- await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
4780
+ }, this.adminEntry(actor, 'publishVersion', { tenantId: null }, null, {
4650
4781
  ...audited,
4651
4782
  // The digest the registry holds, which differs from the pushed one only for an unheld version.
4652
4783
  migrationDigest,
4653
4784
  ...(origin ? { origin } : {}),
4654
4785
  admission: selfAdmits ? 'admitted' : 'pending',
4655
- });
4786
+ }));
4656
4787
  },
4657
4788
  verticalScopeStores: async (actor, verticalSlug) => {
4658
4789
  const stores = await this.directory('verticalScopeStores', verticalSlug);
@@ -4671,90 +4802,43 @@ export class CloudflareScopeHost {
4671
4802
  await this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
4672
4803
  return hit ? mapVersion(hit) : undefined;
4673
4804
  },
4805
+ // #2528: each vertical write below is one DO call that reads the row's `before`, and runs its
4806
+ // guard, in the unit that writes; it refuses an unknown vertical with not_found.
4674
4807
  setVerticalListed: async (actor, slug, listed) => {
4675
- const existing = await this.cp.readVertical(slug);
4676
- if (!existing)
4677
- throw substratError('not_found', `unknown vertical '${slug}'`);
4678
- // Listing is the moment other tenants start trusting this code, so the
4679
- // version they would install must carry a real staff vouch — an auto-admitted
4680
- // prod version has never been read by anyone but its author.
4681
- if (listed) {
4682
- const prod = await this.cp.readChannel(slug, 'prod');
4683
- const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
4684
- if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
4685
- throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
4686
- `a staff admit must vouch for it before listing`);
4687
- }
4688
- }
4689
- await this.cp.updateVerticalListed(slug, listed ? 1 : 0); // also resolves any pending request
4690
- await this.recordAdmin(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed });
4808
+ // Listing is the moment other tenants start trusting this code, so the version they
4809
+ // would install must carry a real staff vouch — an auto-admitted prod version has never
4810
+ // been read by anyone but its author. The DO refuses one; it also resolves any pending
4811
+ // publish request.
4812
+ await this.directory('updateVerticalListed', slug, listed ? 1 : 0, this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, null, { listed }));
4691
4813
  },
4692
4814
  requestPublish: async (actor, slug) => {
4693
- const existing = await this.cp.readVertical(slug);
4694
- if (!existing)
4695
- throw substratError('not_found', `unknown vertical '${slug}'`);
4696
- await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString());
4697
- await this.recordAdmin(actor, 'requestPublish', { tenantId: null }, null, { slug });
4815
+ await this.directory('updateVerticalPublishRequest', slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
4698
4816
  },
4699
4817
  setVerticalInstallsBlocked: async (actor, slug, blocked) => {
4700
- const existing = await this.cp.readVertical(slug);
4701
- if (!existing)
4702
- throw substratError('not_found', `unknown vertical '${slug}'`);
4703
- await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0);
4704
- await this.recordAdmin(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked });
4818
+ await this.directory('updateVerticalInstallsBlocked', slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, null, { installsBlocked: blocked }));
4705
4819
  },
4706
4820
  setVerticalTenantProvisioner: async (actor, slug, granted) => {
4707
- const existing = await this.cp.readVertical(slug);
4708
- if (!existing)
4709
- throw substratError('not_found', `unknown vertical '${slug}'`);
4710
- await this.cp.updateVerticalTenantProvisioner(slug, granted ? 1 : 0);
4711
- await this.recordAdmin(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: !!existing.tenant_provisioner }, { tenantProvisioner: granted });
4821
+ await this.directory('updateVerticalTenantProvisioner', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, null, { tenantProvisioner: granted }));
4712
4822
  },
4713
4823
  setVerticalEmailSender: async (actor, slug, granted) => {
4714
- const existing = await this.cp.readVertical(slug);
4715
- if (!existing)
4716
- throw substratError('not_found', `unknown vertical '${slug}'`);
4717
- await this.cp.updateVerticalEmailSender(slug, granted ? 1 : 0);
4718
- await this.recordAdmin(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: !!existing.email_sender }, { emailSender: granted });
4824
+ await this.directory('updateVerticalEmailSender', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, null, { emailSender: granted }));
4719
4825
  },
4720
4826
  deleteVertical: async (actor, slug) => {
4721
- const existing = await this.cp.readVertical(slug);
4722
- if (!existing)
4723
- throw substratError('not_found', `unknown vertical '${slug}'`);
4724
- // Refuse while any restorable scope is bound: a deleted registry row would strand
4725
- // those scopes' version pins and routing. An `archived` scope (a deleted app) still
4726
- // blocks — unarchive can bring it back — but the refusal names reap/restore, not
4727
- // "delete", because the app itself is already gone. `reaped` is terminal history and
4728
- // never blocks. Deployed dispatch scripts are NOT reaped here — they become orphans
4729
- // for the cleanup script (#248).
4730
- const bound = await this.cp.countScopesForVertical(slug);
4731
- if (bound.live > 0) {
4732
- throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
4733
- }
4734
- if (bound.archived > 0) {
4735
- throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
4736
- }
4737
- await this.cp.deleteVertical(slug);
4738
- await this.recordAdmin(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null);
4827
+ // Refused while any restorable scope is bound (the DO counts them in the delete's unit).
4828
+ // `reaped` is terminal history and never blocks. Deployed dispatch scripts are NOT reaped
4829
+ // here — they become orphans for the cleanup script (#248). #2501: the period's `until`
4830
+ // is read here, on the clock its `since` was.
4831
+ await this.directory('deleteVertical', slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, null, null), verticalOwnerNow());
4832
+ },
4833
+ listVerticalOwners: async (actor, slug) => {
4834
+ const periods = (await this.cp.listVerticalOwners(slug)).map((p) => verticalOwnerPeriod.parse(p));
4835
+ await this.recordAccess(actor, 'listVerticalOwners', { tenantId: null }, { slug }, periods.length);
4836
+ return periods;
4739
4837
  },
4740
4838
  admitVersion: async (actor, versionId) => {
4741
- const v = await this.cp.readVersion(versionId);
4742
- if (!v)
4743
- throw substratError('not_found', `unknown version ${versionId}`);
4744
- if (v.admission === 'admitted') {
4745
- // Idempotent — except an AUTO-admitted version, which this upgrades to a
4746
- // manual vouch by clearing the note (what the publish seam requires).
4747
- if (v.admission_note !== AUTO_ADMISSION_NOTE)
4748
- return;
4749
- await this.cp.setAdmission(versionId, 'admitted', null);
4750
- await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admission_note }, { admission: 'admitted', note: null });
4751
- return;
4752
- }
4753
- if (v.admission === 'rejected') {
4754
- throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
4755
- }
4756
- await this.cp.setAdmission(versionId, 'admitted', null);
4757
- await this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' });
4839
+ // Judged in the DO's unit (#2528): idempotent on an admitted version, except an AUTO-admitted
4840
+ // one, which it upgrades to a manual vouch; a rejected one is refused. The DO fills the row.
4841
+ await this.directory('admitVersion', versionId, this.adminEntry(actor, 'admitVersion', { tenantId: null }, null, null));
4758
4842
  },
4759
4843
  promotionImpact: async (actor, verticalSlug, channel, versionId) => {
4760
4844
  const incoming = await this.cp.readVersion(versionId);
@@ -4770,16 +4854,8 @@ export class CloudflareScopeHost {
4770
4854
  return breaks;
4771
4855
  },
4772
4856
  rejectVersion: async (actor, versionId, note) => {
4773
- const v = await this.cp.readVersion(versionId);
4774
- if (!v)
4775
- throw substratError('not_found', `unknown version ${versionId}`);
4776
- if (v.admission === 'admitted') {
4777
- throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
4778
- }
4779
- if (v.admission === 'rejected')
4780
- return;
4781
- await this.cp.setAdmission(versionId, 'rejected', note);
4782
- await this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note });
4857
+ // Judged in the DO's unit (#2528): an admitted version is refused, a rejected one is a no-op.
4858
+ await this.directory('rejectVersion', versionId, note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, null, { admission: 'rejected', note }));
4783
4859
  },
4784
4860
  promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
4785
4861
  const incoming = await this.cp.readVersion(versionId);
@@ -4853,7 +4929,8 @@ export class CloudflareScopeHost {
4853
4929
  // in. The control plane moves the pointer, and writes the timeline row (what makes rollback a
4854
4930
  // choice among recorded moments, and `at` the PITR anchor a data rollback would rewind to),
4855
4931
  // only if the pointer still names `outgoing`; the promote that lost the race is refused as
4856
- // stale and writes nothing.
4932
+ // stale and writes nothing. Its admin row commits with the move, ahead of the rebinds below,
4933
+ // which record their own — and the acknowledgement is recorded, not just enforced.
4857
4934
  const moved = await this.cp.moveChannel(outgoing?.id ?? null, {
4858
4935
  id: ulid(),
4859
4936
  vertical_slug: verticalSlug,
@@ -4862,7 +4939,7 @@ export class CloudflareScopeHost {
4862
4939
  from_version_id: outgoing?.id ?? null,
4863
4940
  actor,
4864
4941
  at: promotedAt,
4865
- });
4942
+ }, this.adminEntry(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack }));
4866
4943
  if (!moved)
4867
4944
  throw promoteRaceRefusal(channel);
4868
4945
  // For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
@@ -4892,7 +4969,6 @@ export class CloudflareScopeHost {
4892
4969
  }
4893
4970
  await this.cp.bindScopeVersion(s.scope_id, versionId, verticalSlug, undefined, undefined, undefined, undefined, undefined, this.adminEntry(actor, 'bindScopeVersion', { tenantId: s.tenant_id, scopeId: s.scope_id }, prev ? { versionId: prev.id, version: prev.version } : null, { versionId, vertical: verticalSlug, version: incoming.version, via: 'promoteVersion' }));
4894
4971
  }
4895
- await this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
4896
4972
  },
4897
4973
  listChannels: async (actor, verticalSlug, page) => {
4898
4974
  // `prod` is the only live channel (#509 retired dev/staging). Filter before the parse
@@ -5056,15 +5132,16 @@ export class CloudflareScopeHost {
5056
5132
  await this.assertNoStrandFrom([r.serving_version_id], incoming.id, incoming, r.serving_ref ? await this.cp.provisionedVersionsOf(verticalSlug, { ref: r.serving_ref, versionId: r.serving_version_id }, null) : []);
5057
5133
  }
5058
5134
  }
5059
- await this.cp.setVerticalServing(verticalSlug, {
5135
+ // #2528: a compare-and-set on the serving ref and version the gate above judged, across
5136
+ // awaits another serve can run in. The DO fills the row's `before` from the state it moves.
5137
+ const moved = await this.directory('setVerticalServing', verticalSlug, {
5060
5138
  ref: parsed.ref,
5061
5139
  versionId: parsed.versionId,
5062
5140
  doClassesJson: JSON.stringify(parsed.doClasses),
5063
5141
  migrationTag: parsed.migrationTag,
5064
- });
5065
- await this.recordAdmin(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref
5066
- ? { ref: r.serving_ref, versionId: r.serving_version_id }
5067
- : null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId });
5142
+ }, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }), { ref: r.serving_ref ?? null, versionId: r.serving_version_id ?? null });
5143
+ if (!moved)
5144
+ throw servingRaceRefusal(verticalSlug);
5068
5145
  },
5069
5146
  versionManifest: async (actor, verticalSlug, versionId) => {
5070
5147
  const v = await this.cp.readVersion(versionId);
@@ -5082,6 +5159,21 @@ export class CloudflareScopeHost {
5082
5159
  await this.recordAccess(actor, 'versionMigrations', {}, { verticalSlug, versionId }, v.migrations?.length ?? 0);
5083
5160
  return v.migrations;
5084
5161
  },
5162
+ recordWireAuth: async (actor, input) => {
5163
+ const row = deploymentWireAuth.parse(input);
5164
+ const written = await this.cp.recordWireAuth(row, this.adminEntry(actor, 'recordWireAuth', { tenantId: null, vertical: row.verticalSlug }, null, { vertical: row.verticalSlug, deploymentRef: row.deploymentRef, versionId: row.versionId, record: row.record }));
5165
+ // Thrown here, not in the directory's object, so the code survives the RPC.
5166
+ if (!written)
5167
+ throw substratError('not_found', `no capability record for '${row.deploymentRef}': vertical '${row.verticalSlug}' is not registered`);
5168
+ },
5169
+ listWireAuth: async (actor, input) => {
5170
+ // Judged here, before the call into the directory's object, so a refusal keeps its code; the
5171
+ // judged copy is what crosses the RPC and reaches the log (its own fields, the reach first).
5172
+ const filter = assertWireAuthFilter(input);
5173
+ const rows = await this.cp.listWireAuth(filter);
5174
+ await this.recordAccess(actor, 'listWireAuth', wireAuthAccessTarget(filter), filter, rows.length);
5175
+ return rows;
5176
+ },
5085
5177
  setScopeServingRef: async (actor, tenantId, scopeId, servingRef, opts) => {
5086
5178
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
5087
5179
  if (!scope)
@@ -5144,6 +5236,13 @@ export class CloudflareScopeHost {
5144
5236
  await this.recordAccess(actor, 'scopeStoreSizes', { tenantId, scopeId }, null, sizes.length);
5145
5237
  return sizes;
5146
5238
  },
5239
+ scopeAttachmentBytes: async (actor, tenantId, scopeId) => {
5240
+ // Reaped is refused, not read: addressing the deleted DO would recreate it.
5241
+ await this.scopeRecordForRead(tenantId, scopeId);
5242
+ const bytes = await this.scopeStub(scopeId).attachmentBytes();
5243
+ await this.recordAccess(actor, 'scopeAttachmentBytes', { tenantId, scopeId }, null, 1);
5244
+ return bytes;
5245
+ },
5147
5246
  rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
5148
5247
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
5149
5248
  if (!scope)
@@ -5228,31 +5327,26 @@ export class CloudflareScopeHost {
5228
5327
  },
5229
5328
  createTenant: async (actor, input) => {
5230
5329
  const parsed = createTenantInput.parse(input);
5231
- const created = await this.directory('createTenant', parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null);
5232
- // Idempotent: re-creating an existing tenant is a no-op, not audited.
5233
- if (!created)
5234
- return;
5235
- await this.recordAdmin(actor, 'createTenant', { tenantId: parsed.id }, null, created);
5330
+ // Idempotent: re-creating an existing tenant is a no-op, not audited. The DO writes the
5331
+ // row with the tenant it created as its `after`.
5332
+ await this.directory('createTenant', parsed.id, parsed.slug, parsed.name, new Date().toISOString(), parsed.provisionedByTenant ?? null, this.adminEntry(actor, 'createTenant', { tenantId: parsed.id }, null, null));
5236
5333
  },
5237
5334
  setTenantStatus: async (actor, tenantId, status) => {
5238
- const before = await this.directory('setTenantStatus', tenantId, status);
5239
- await this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before }, { status });
5335
+ // The DO fills the row's `before` with the status it read in the write's unit.
5336
+ await this.directory('setTenantStatus', tenantId, status, this.adminEntry(actor, 'setTenantStatus', { tenantId }, null, { status }));
5240
5337
  // #1713: every hosted scope under the tenant holds or resumes its work by this.
5241
5338
  await this.deliverLifecycles(actor, { tenantId });
5242
5339
  },
5243
5340
  setTenantName: async (actor, tenantId, name) => {
5244
- const before = await this.directory('setTenantName', tenantId, name);
5245
- if (before === name)
5246
- return; // no-op is not audited — nothing changed
5247
- await this.recordAdmin(actor, 'setTenantName', { tenantId }, { name: before }, { name });
5341
+ // A rename to the current name writes nothing and is not audited; the DO fills `before`.
5342
+ await this.directory('setTenantName', tenantId, name, this.adminEntry(actor, 'setTenantName', { tenantId }, null, { name }));
5248
5343
  },
5249
5344
  reapTenant: async (actor, tenantId) => {
5250
5345
  // Directory-side terminal reap (§4.8). The caller reaped every scope's storage
5251
5346
  // first (archive-if-needed → reapScope in the vertical deployment); the DO clears
5252
5347
  // the tenant's PII/config rows and flips the row to a `reaped` tombstone, keeping
5253
5348
  // the row + admin log. Only a `deleting` tenant may be reaped (checked in the DO).
5254
- const before = await this.directory('reapTenant', tenantId);
5255
- await this.recordAdmin(actor, 'reapTenant', { tenantId }, { status: before }, { status: 'reaped' });
5349
+ await this.directory('reapTenant', tenantId, this.adminEntry(actor, 'reapTenant', { tenantId }, null, { status: 'reaped' }));
5256
5350
  },
5257
5351
  listTenants: async (actor, page) => {
5258
5352
  const tenants = (await this.cp.listTenants(page)).map((t) => tenantSchema.parse(t));
@@ -5266,21 +5360,15 @@ export class CloudflareScopeHost {
5266
5360
  return t ? tenantSchema.parse(t) : undefined;
5267
5361
  },
5268
5362
  listScopes: async (actor, filter) => {
5269
- const rows = await this.cp.listScopes({
5270
- tenantId: filter?.tenantId,
5271
- status: filter?.status
5272
- ? Array.isArray(filter.status)
5273
- ? filter.status
5274
- : [filter.status]
5275
- : undefined,
5276
- vertical: filter?.vertical,
5277
- limit: filter?.limit,
5278
- cursor: filter?.cursor,
5279
- order: filter?.order,
5280
- });
5363
+ const rows = await scopeRows(filter);
5281
5364
  await this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
5282
5365
  return rows.map(mapScope);
5283
5366
  },
5367
+ listScopesReadable: async (actor, filter) => {
5368
+ const rows = await scopeRows(filter);
5369
+ await this.recordAccess(actor, 'listScopesReadable', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
5370
+ return readableScopes(rows, mapScope);
5371
+ },
5284
5372
  getScopeRecord: async (actor, tenantId, scopeId) => {
5285
5373
  const row = await this.cp.getScopeRecord(tenantId, scopeId);
5286
5374
  await this.recordAccess(actor, 'getScopeRecord', { tenantId, scopeId }, null, row ? 1 : 0);
@@ -5373,6 +5461,9 @@ export class CloudflareScopeHost {
5373
5461
  recordStoreDriverRebuildRun: async (actor, entry) => {
5374
5462
  await this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry);
5375
5463
  },
5464
+ recordWireWalk: async (actor, entry) => {
5465
+ await this.recordAdmin(actor, 'walkWireAuth', { tenantId: null }, null, entry);
5466
+ },
5376
5467
  listScopeTables: async (actor, tenantId, scopeId) => {
5377
5468
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
5378
5469
  // that does not resolve is unreachable, never another tenant's database.
@@ -5984,6 +6075,7 @@ export class CloudflareScopeHost {
5984
6075
  storage: rows.storage,
5985
6076
  storageAttempts: rows.storageAttempts,
5986
6077
  storeStorage: rows.storeStorage,
6078
+ attachmentStorage: rows.attachmentStorage,
5987
6079
  });
5988
6080
  // Tenants covered, not totals: "read one tenant's meter" and "metered the whole
5989
6081
  // fleet" are different acts, and K-24 exists to tell them apart.
@@ -6388,7 +6480,10 @@ export class CloudflareScopeHost {
6388
6480
  return rows.map((r) => adminLogEntry.parse(r));
6389
6481
  },
6390
6482
  recordOpsFailure: async (entry) => {
6483
+ // #2540: a pair's staff row is minted first, so the tenant's row lists above it.
6484
+ const detail = entry.tenantId && typeof entry.detail === 'string' ? { detail: entry.detail.slice(0, 2000), detail_id: ulid() } : {};
6391
6485
  await this.cp.recordOpsFailure({
6486
+ ...detail,
6392
6487
  id: ulid(),
6393
6488
  actor: entry.actor,
6394
6489
  operation: entry.operation,
@@ -6418,6 +6513,7 @@ export class CloudflareScopeHost {
6418
6513
  code: filter?.code,
6419
6514
  fingerprint: filter?.fingerprint,
6420
6515
  reference: filter?.reference,
6516
+ referenceInText: filter?.referenceInText,
6421
6517
  since: filter?.since,
6422
6518
  until: filter?.until,
6423
6519
  limit: filter?.limit,
@@ -6534,6 +6630,30 @@ export class CloudflareScopeHost {
6534
6630
  return rows;
6535
6631
  },
6536
6632
  pruneScopeStorage: async (_actor, limit) => this.cp.pruneScopeStorage(assertRowLimit('limit', limit)),
6633
+ recordRequestDay: async (_actor, input) => this.cp.recordRequestDay(input),
6634
+ listRequestDays: async (actor, filter) => {
6635
+ const rows = (await this.cp.listRequestDays(filter)).map((r) => requestDay.parse(r));
6636
+ await this.recordAccess(actor, 'listRequestDays', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter ?? null, rows.length);
6637
+ return rows;
6638
+ },
6639
+ listRequestRollupDays: async (actor, filter) => {
6640
+ const rows = (await this.cp.listRequestRollupDays(filter)).map((r) => requestRollupDay.parse(r));
6641
+ await this.recordAccess(actor, 'listRequestRollupDays', { tenantId: null }, filter ?? null, rows.length);
6642
+ return rows;
6643
+ },
6644
+ pruneRequestDays: async (_actor, limit, retentionMonths) => this.cp.pruneRequestDays(assertRowLimit('limit', limit), retentionMonths),
6645
+ recordMeterDay: async (_actor, input) => this.cp.recordMeterDay(input),
6646
+ listMeterDays: async (actor, filter) => {
6647
+ const rows = (await this.cp.listMeterDays(filter)).map((r) => meterDay.parse(r));
6648
+ await this.recordAccess(actor, 'listMeterDays', { tenantId: filter?.tenantId ?? null }, filter ?? null, rows.length);
6649
+ return rows;
6650
+ },
6651
+ listMeterDayTakes: async (actor, filter) => {
6652
+ const rows = (await this.cp.listMeterDayTakes(filter)).map((r) => meterDayTake.parse(r));
6653
+ await this.recordAccess(actor, 'listMeterDayTakes', { tenantId: null }, filter ?? null, rows.length);
6654
+ return rows;
6655
+ },
6656
+ pruneMeterDays: async (_actor, limit, retentionMonths) => this.cp.pruneMeterDays(assertRowLimit('limit', limit), retentionMonths),
6537
6657
  recordModelUsage: async (input) => {
6538
6658
  const l = input.line;
6539
6659
  return this.cp.recordModelUsage({
@@ -6756,15 +6876,11 @@ export class CloudflareScopeHost {
6756
6876
  catch (err) {
6757
6877
  report.failed += 1;
6758
6878
  try {
6759
- await this.admin.recordOpsFailure({
6760
- actor,
6761
- operation: 'scope.lifecycle',
6762
- stage: 'deliver',
6763
- tenantId,
6764
- scopeId,
6765
- message: `the lifecycle (scope ${t.scope_status}, tenant ${t.tenant_status}) did not reach the ` +
6766
- `deployment serving this scope; the heal sweep will deliver it again: ${err instanceof Error ? err.message : String(err)}`,
6767
- });
6879
+ // #2554: the throw is the deployment's or its runtime's text, and the deployment serving
6880
+ // a scope is the vertical's code, often another tenant's. The tenant's row says only what
6881
+ // happened; the text is staff's row, under the same reference.
6882
+ const missed = `the lifecycle (scope ${t.scope_status}, tenant ${t.tenant_status}) did not reach the deployment serving this scope; the heal sweep will deliver it again`;
6883
+ await this.admin.recordOpsFailure(withheldOpsFailure({ actor, operation: 'scope.lifecycle', stage: 'deliver', tenantId, scopeId }, `${missed}: ${err instanceof Error ? err.message : String(err)}`, (reference) => `${missed} (reference ${reference})`));
6768
6884
  }
6769
6885
  catch (recordErr) {
6770
6886
  console.error('substrat: could not record a lifecycle delivery failure (#1713)', recordErr);
@@ -7564,6 +7680,14 @@ export class CloudflareScopeHost {
7564
7680
  // #2430: refused before anything is written, rather than at the top-up after the projection.
7565
7681
  if (input.entityGrants)
7566
7682
  await this.refuseReservedShapes(input.entityGrants, input.scopeId);
7683
+ // #2447: the role set this provision defines, and the owner's seat in it — a person's seat, so
7684
+ // the person's door — are judged before anything is written. A refusal fails the provision and
7685
+ // writes nothing, so a seat a reconcile would have re-asserted is left exactly as it is.
7686
+ const reserved = await this.refuseReservedRoleSet(input.roles, input.tenantId, input.scopeId);
7687
+ const ownerRole = input.roles.find((r) => r.key === input.ownerRoleKey);
7688
+ const ownerRefusal = ownerRole ? reservedRoleRefusal(ownerRole, reserved, 'person') : null;
7689
+ if (ownerRefusal)
7690
+ throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
7567
7691
  const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
7568
7692
  const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
7569
7693
  const stub = this.scopeStub(input.scopeId);
@@ -7639,6 +7763,136 @@ export class CloudflareScopeHost {
7639
7763
  ...(connections.skipped.length ? { skippedConnectionGrants: connections.skipped } : {}),
7640
7764
  };
7641
7765
  }
7766
+ /**
7767
+ * #2447: what a tenant's role definition is judged with, beyond the stored roles the ControlPlaneDO
7768
+ * reads in its own unit:
7769
+ * - the reservations of this host's modules, joined with the scope class's. Every scope on this
7770
+ * binding is one class, so one of the tenant's scopes answers for all, and the fan-out after
7771
+ * the write projects into exactly those scopes. A tenant with no scope yet is judged by this
7772
+ * host's modules alone (a stated residual: its first scope's projection is not judged again);
7773
+ * - for a `holder: 'system'` definition of a key with no system-held definition stored (none at
7774
+ * all included: a seat can outlive its role's row), whether anyone but a system principal holds a
7775
+ * live scope-level seat in it in one of those scopes (the ControlPlaneDO reads the tenant-level
7776
+ * seats). No scope is asked for seats otherwise.
7777
+ *
7778
+ * Every scope that is not reaped is read, a scope archived straight from `provisioning` included:
7779
+ * the platform's door seats into a provisioning scope, and unarchive brings the seat back. A DO
7780
+ * cannot be asked anything without waking it, and waking one that never ran would create storage it
7781
+ * never had (#1524), so such a scope is read only when its directory row records a migration
7782
+ * (`schema_version` past `'0'`). That is not a mark of instantiation: it counts the module
7783
+ * migrations applied, and is written only when a pass applied one, so a scope of a class with no
7784
+ * module migrations reads `'0'` though it holds a seat. One that records none is not read, and the
7785
+ * definition fails closed where that scope could decide it: `unreadable` names it, and the caller
7786
+ * refuses a definition its seats, or its class's reservations when no other scope gives them, would
7787
+ * decide. So while such a scope is the tenant's only one, every definition is refused until it is
7788
+ * unarchived or reaped. No product path reaches this today: the dashboard is the one Cloudflare
7789
+ * caller of `defineRole`, and its class has migrations.
7790
+ *
7791
+ * A stated residual: this half is read before the ControlPlaneDO's unit, not in it, so a scope-level
7792
+ * seat made in between is not seen. The tenant-level seats and the stored roles are read in the unit.
7793
+ */
7794
+ async definitionContext(tenantId, role) {
7795
+ const listed = await this.cp.listScopes({ tenantId, status: [...SEAT_HOLDING_SCOPE_STATUSES] });
7796
+ const neverStored = (s) => (s.status === 'archived' || s.status === 'archiving') && s.archived_from_status === 'provisioning' && (s.schema_version ?? '0') === '0';
7797
+ const scopes = listed.filter((s) => !neverStored(s));
7798
+ const unreadableScope = listed.find(neverStored);
7799
+ const unreadable = unreadableScope
7800
+ ? `scope ${unreadableScope.scope_id} was archived straight from provisioning and records no migration, so its seats and its ` +
7801
+ `class's reservations cannot be read without creating storage it may never have had. Unarchive it (or reap it) first`
7802
+ : null;
7803
+ const reservation = { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
7804
+ if (scopes.length > 0) {
7805
+ const here = await this.scopeStub(scopes[0].scope_id).reservationHere();
7806
+ reservation.reserved = [...new Set([...reservation.reserved, ...here.reserved])];
7807
+ reservation.declared = [...new Set([...reservation.declared, ...here.declared])];
7808
+ }
7809
+ let seatedInScopes = false;
7810
+ let seatsDecide = false;
7811
+ if (role.holder === 'system') {
7812
+ const stored = await this.cp.getRole(tenantId, role.key);
7813
+ if (stored?.holder !== 'system') {
7814
+ seatsDecide = true;
7815
+ for (const s of scopes) {
7816
+ if ((await this.scopeStub(s.scope_id).roleStateHere(tenantId)).seated.includes(role.key)) {
7817
+ seatedInScopes = true;
7818
+ break;
7819
+ }
7820
+ }
7821
+ }
7822
+ }
7823
+ // Fail closed only where the unread scope could decide: its seats (a system-held definition the
7824
+ // seats judge), or the class's reservations when no other scope gave them.
7825
+ const decides = unreadable !== null && ((seatsDecide && !seatedInScopes) || scopes.length === 0);
7826
+ return { reservation, seatedInScopes, unreadable: decides ? unreadable : null };
7827
+ }
7828
+ /**
7829
+ * #2447: why `roles` may not be defined on `scopeId`, judged as a set against this host's
7830
+ * reservations joined with the scope class's, or null. A restore asks it before it imports a dump,
7831
+ * which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
7832
+ * roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
7833
+ * staged restore has not (its pages are already in the scope), so it is judged as a set alone
7834
+ * here, and `projectRolesLocal` judges again after the import, against what the dump brought.
7835
+ */
7836
+ async roleSetRefusalLocal(scopeId, roles, dump) {
7837
+ const here = await this.scopeStub(scopeId).reservationHere();
7838
+ return reservedRoleSetRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump ? dumpRoleState(dump.tables, dump.tenantId, new Date().toISOString()) : undefined);
7839
+ }
7840
+ /**
7841
+ * #2555: refuse loading `dump` into a scope of `tenantId` (`dumpRoleRefusal`): the tenant's roles as
7842
+ * the directory stores them now, against the dump's role rows and seats, with this host's
7843
+ * reservations joined with the scope class's. The class is read at `scopeId` when the scope exists
7844
+ * (a restore), else at a live scope of the tenant (an import, whose scope is not created yet: asking
7845
+ * its object would create storage for a scope a refusal leaves unmade).
7846
+ *
7847
+ * Two stated residuals. The directory is read here and the dump is loaded in the scope's own call,
7848
+ * so a role defined between the two is not judged against the dump's seats. And an import for a
7849
+ * tenant with no live scope reads no class, so it is judged with this host's reservations alone: the
7850
+ * seat rule holds without them, and a key only the class reserves is not counted.
7851
+ */
7852
+ async refuseDumpRoles(tenantId, scopeId, dump) {
7853
+ const classScope = scopeId ?? (await this.cp.listScopes({ tenantId, status: ['active', 'suspended'], limit: 1 }))[0]?.scope_id;
7854
+ const [rows, here] = await Promise.all([
7855
+ this.cp.listRoles({ tenantId }),
7856
+ classScope ? this.scopeStub(classScope).reservationHere() : { reserved: [], declared: [] },
7857
+ ]);
7858
+ const roles = rows.map((r) => ({ key: r.role_key, permissions: JSON.parse(r.permissions), ...(r.holder ? { holder: r.holder } : {}) }));
7859
+ const refusal = dumpRoleRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump, new Date().toISOString());
7860
+ if (refusal)
7861
+ throw substratError('forbidden', refusal);
7862
+ }
7863
+ /**
7864
+ * #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
7865
+ * holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
7866
+ * (`roleStateHere`), read for a promote to judge the incoming version's set with
7867
+ * (`releaseRoleSetRefusal`) before it moves the scope. Read-only, and names no principal.
7868
+ */
7869
+ async roleStateLocal(tenantId, scopeId) {
7870
+ return this.scopeStub(scopeId).roleStateHere(tenantId);
7871
+ }
7872
+ /**
7873
+ * #2447: refuse a role set a CP-less provision or restore defines on `scopeId` for `tenantId`
7874
+ * (`reservedRoleSetRefusal`), against this host's reservations joined with the scope class's,
7875
+ * and the roles the scope stores for the tenant now with their seats there. Answers the reserved
7876
+ * keys it judged with, for the seats the same call makes.
7877
+ *
7878
+ * A stated residual: the scope state is read in one RPC (`roleStateHere`) and written in another
7879
+ * (`applyProjectionReply`), so a seat made between the two is not seen. The window is one call
7880
+ * wide, and a reconcile runs again every pass.
7881
+ */
7882
+ async refuseReservedRoleSet(roles, tenantId, scopeId) {
7883
+ const stub = this.scopeStub(scopeId);
7884
+ const [here, state] = await Promise.all([stub.reservationHere(), stub.roleStateHere(tenantId)]);
7885
+ const reserved = new Set([...this.reservedKeys, ...here.reserved]);
7886
+ const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) },
7887
+ // #2531: with the tombstones the scope keeps, so a system-held role a release dropped is judged
7888
+ // as the stored definition it was. A ScopeDO from before them answers none.
7889
+ { roles: state.roles, seated: new Set(state.seated), ...(state.dropped ? { dropped: state.dropped } : {}) });
7890
+ // Named by tenant and scope as well as role: a release push admitted can be refused here at every
7891
+ // scope where the role is seated, and staff find each one by this line (K-55).
7892
+ if (refusal)
7893
+ throw substratError('forbidden', `tenant ${tenantId}, scope ${scopeId}: the vertical's roles cannot be defined here: ${refusal}`);
7894
+ return reserved;
7895
+ }
7642
7896
  /**
7643
7897
  * #2430: the delivered connection grants a provision or reconcile may seat, and those it skips. A
7644
7898
  * connection acts for the external provider holding its token, so a grant naming a key reserved
@@ -7734,15 +7988,26 @@ export class CloudflareScopeHost {
7734
7988
  * storage. Idempotent (writeTuple is INSERT OR REPLACE). `roleKey` must be one the scope
7735
7989
  * already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
7736
7990
  */
7737
- async assignScopeRole(scopeId, principal, roleKey) {
7738
- // #2430: a platform door, as `HostAdmin.assignRole` is — a role mixing reserved and other keys
7739
- // is refused, against the scope class's own reservations and the role as the checker reads it
7740
- // for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens without one).
7741
- const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, 'platform');
7991
+ async assignScopeRole(scopeId, principal, roleKey,
7992
+ /** #2447: `person` when the caller knows it seats a person (the owner hand-over); the platform's door otherwise. */
7993
+ door = 'platform') {
7994
+ // #2430: a platform door by default, as `HostAdmin.assignRole` is — a role mixing reserved and
7995
+ // other keys is refused, against the scope class's own reservations and the role as the checker
7996
+ // reads it for the tenant the scope's receipt names (`reservedRoleRefusalHere` says what happens
7997
+ // without one). The person's door also refuses any reserved key and a system-held role (#2447).
7998
+ const refusal = await this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, door);
7742
7999
  if (refusal)
7743
8000
  throw substratError('forbidden', refusal);
7744
8001
  unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
7745
8002
  }
8003
+ /**
8004
+ * #2447: why `roleKey` may not be seated at scope level through `door`, or null — the question
8005
+ * `assignScopeRole` asks before it writes, for a caller (the owner hand-over) that must ask it
8006
+ * before anything else moves.
8007
+ */
8008
+ async scopeRoleRefusal(scopeId, roleKey, door) {
8009
+ return this.scopeStub(scopeId).reservedRoleRefusalHere(roleKey, door);
8010
+ }
7746
8011
  /**
7747
8012
  * Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
7748
8013
  * A tombstone, exactly as `HostAdmin.unassignRole` writes for a scope-level assignment:
@@ -7904,6 +8169,17 @@ export class CloudflareScopeHost {
7904
8169
  * so a caller falls back rather than failing a dispatch over a missing file.
7905
8170
  */
7906
8171
  async connectorAttachmentOpenLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
8172
+ return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).open(attachmentId);
8173
+ }
8174
+ /**
8175
+ * `connectorAttachmentOpenLocal`, with the bytes as a stream read from R2 as the platform reads
8176
+ * the response (#2514). The same gates, in the same order, before the bucket is touched.
8177
+ */
8178
+ async connectorAttachmentOpenStreamLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
8179
+ return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).openStream(attachmentId);
8180
+ }
8181
+ /** The attachment surface the platform's reads as a connection go through, behind their gates. */
8182
+ async connectorReadSurface(connectionId, tenantId, scopeId, eventId) {
7907
8183
  // Same gate as the upload leg: a held scope's bytes are not read out of its bucket.
7908
8184
  await this.assertLive(tenantId, scopeId);
7909
8185
  await this.migrateAndRecord(scopeId);
@@ -7911,7 +8187,7 @@ export class CloudflareScopeHost {
7911
8187
  return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store,
7912
8188
  // #726 remedy B: the platform named a delivery; this deployment decides what that
7913
8189
  // delivery reaches, from its own spine row. Absent ⇒ the ordinary grant check.
7914
- eventId === undefined ? undefined : { eventId }).open(attachmentId);
8190
+ eventId === undefined ? undefined : { eventId });
7915
8191
  }
7916
8192
  /**
7917
8193
  * The delivery half of `grantToConnection` for a scope served HERE (#574): write