@substrat-run/adapter-cloudflare 0.160.0 → 0.162.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,9 +1,9 @@
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, 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';
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, errorCodeOf, 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, isSizedByteStream, 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, 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, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } 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
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';
@@ -75,6 +75,10 @@ const connectionGrantsSkippedLine = (tenantId, scopeId, skipped) => ({
75
75
  * classification is the platform directory's; the vertical applies the same `isPrimaryScope` to
76
76
  * it, so a primary can be marked only by a request that misstates its lineage, never by default.
77
77
  */
78
+ /** A vertical's role definitions as a CP-less projection writes them (`applyProjection`'s rows). */
79
+ function projectedRoleRows(roles) {
80
+ return roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' }));
81
+ }
78
82
  function assertCopyLineage(lineage) {
79
83
  if (isPrimaryScope(lineage)) {
80
84
  throw substratError('conflict', 'mark-copy refused: the directory classifies this scope as primary, not as a copy');
@@ -349,11 +353,11 @@ const DOOR_WORDS = {
349
353
  };
350
354
  /** #2029: the rewind hold's key for one subject — a module's id, bare as #1819 stored it, or `vertical:<slug>`. */
351
355
  const holdKeyOf = (kind, key) => (kind === 'system' ? key : peerSubjectRef(key));
352
- /** #2541: a vertical-side size read's answer, or the refusal when the scope's own object said it holds nothing (`null`). */
353
- function heldOrRefused(scopeId, size) {
354
- if (size === null)
356
+ /** #2541, #2544: a vertical-side read's answer, or the refusal when the scope's own object said it holds nothing (`null`). */
357
+ function heldOrRefused(scopeId, answer) {
358
+ if (answer === null)
355
359
  throw substratError('conflict', scopeNotHeldRefusal(scopeId));
356
- return size;
360
+ return answer;
357
361
  }
358
362
  export class CloudflareScopeHost {
359
363
  admin;
@@ -578,7 +582,8 @@ export class CloudflareScopeHost {
578
582
  grants: async () => (await this.connectionGrantsInScope(tenantId, scopeId))
579
583
  .filter((g) => g.connectionId === open.id)
580
584
  .map((g) => g.permission),
581
- openAttachment: (attachmentId) => this.getConnectorAttachments(open.id, scopeId, { eventId }).then((a) => a.open(attachmentId)),
585
+ openAttachment: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).open(attachmentId),
586
+ openAttachmentStream: async (attachmentId) => (await this.getConnectorAttachments(open.id, scopeId, { eventId })).openStream(attachmentId),
582
587
  // #687: open a cell the scope sealed TO this connection. The keyId-indexed
583
588
  // map goes in, never out — a connector receives the plaintext of one cell,
584
589
  // not a private key it could mislay. Retired keys are in the map too, so a
@@ -1005,14 +1010,29 @@ export class CloudflareScopeHost {
1005
1010
  * gate (the caller is the control plane, which did the K-3 check + audit on its side).
1006
1011
  */
1007
1012
  async introspectScopeTables(scopeId) {
1008
- return this.scopeStub(scopeId).introspectTables();
1013
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTablesIfHeld());
1009
1014
  }
1015
+ /**
1016
+ * The Data view's table page (#2563): refused `conflict` with the shared sentence for a scope the
1017
+ * deployment does not hold, as the reads above are, never answered as an empty store. The carry
1018
+ * and its cleanup read a wiped copy's tombstone, which is not held by design: they use
1019
+ * `readTombstoneTable`.
1020
+ */
1010
1021
  async introspectScopeTable(scopeId, input) {
1022
+ const reply = heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectTableIfHeld(input.table, input.limit, input.offset));
1023
+ return unwrapReply(reply);
1024
+ }
1025
+ /**
1026
+ * A table of a store this deployment may no longer HOLD, for the carry and its cleanup, which ask
1027
+ * a wiped copy whether it carries the `carried_away` tombstone (#2563). Not guarded: a wiped copy
1028
+ * answers nothing else. Not for a screen or a console; those use `introspectScopeTable`.
1029
+ */
1030
+ async readTombstoneTable(scopeId, input) {
1011
1031
  return unwrapReply(await this.scopeStub(scopeId).introspectTableReply(input.table, input.limit, input.offset));
1012
1032
  }
1013
- /** The SQL console's CP-less path (#219) — same trust line as the pair above. */
1033
+ /** The SQL console's CP-less path (#219): guarded as the table page is (#2563). */
1014
1034
  async introspectScopeQuery(scopeId, input) {
1015
- return unwrapReply(await this.scopeStub(scopeId).introspectQueryReply(input.sql));
1035
+ return unwrapReply(heldOrRefused(scopeId, await this.scopeStub(scopeId).introspectQueryIfHeld(input.sql)));
1016
1036
  }
1017
1037
  /**
1018
1038
  * The K-35 denial log's CP-less path (#867) — same trust line as the reads above.
@@ -1108,7 +1128,7 @@ export class CloudflareScopeHost {
1108
1128
  * #2016: `tenantId`, the tenant the platform restores for: recorded as the scope's receipt in
1109
1129
  * the load's own transaction, and a store provisioned for another tenant refuses the load. */
1110
1130
  opts) {
1111
- const out = await this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
1131
+ const out = await this.loadLocal(scopeId, opts, (load, repair) => repair ? this.scopeStub(scopeId).importDumpJudged(tables, scopeId, load, repair) : this.scopeStub(scopeId).importDumpChecked(tables, scopeId, load));
1112
1132
  return { ...out, tables: tables.length };
1113
1133
  }
1114
1134
  /**
@@ -1130,7 +1150,9 @@ export class CloudflareScopeHost {
1130
1150
  }
1131
1151
  /** `restoreScopeLocal` from the dump staged under `stageId` (#2450), with the same fences and answers. */
1132
1152
  async restoreStagedScopeLocal(scopeId, stageId, opts) {
1133
- return this.loadLocal(scopeId, opts, (load) => this.scopeStub(scopeId).commitStagedDumpChecked(stageId, scopeId, load));
1153
+ return this.loadLocal(scopeId, opts, (load, repair) => repair
1154
+ ? this.scopeStub(scopeId).commitStagedDumpJudged(stageId, scopeId, load, repair)
1155
+ : this.scopeStub(scopeId).commitStagedDumpChecked(stageId, scopeId, load));
1134
1156
  }
1135
1157
  /** Drop the dump staged under `stageId` (#2450), if it is still there. */
1136
1158
  async discardStagedScopeLocal(scopeId, stageId) {
@@ -1140,6 +1162,11 @@ export class CloudflareScopeHost {
1140
1162
  async loadLocal(scopeId, opts, run) {
1141
1163
  if (opts?.markCopy)
1142
1164
  assertCopyLineage(opts.markCopy);
1165
+ if (opts?.roles && !opts.tenantId) {
1166
+ throw substratError('validation_failed', 'restore refused: a role repair needs the tenant it is projected for; nothing was loaded');
1167
+ }
1168
+ // #2531: the repair the load judges and projects in its own transaction.
1169
+ const repair = opts?.roles && opts.tenantId ? { tenantId: opts.tenantId, roles: projectedRoleRows(opts.roles), judge: this.roleJudge() } : undefined;
1143
1170
  // #2314: a load replaces the scope's rows, so the orphan sweep is fenced first, as for a rewind.
1144
1171
  await this.fenceOrphanSweep(scopeId, opts?.tenantId);
1145
1172
  const carry = opts ? recordedOffFromWire(opts) : undefined;
@@ -1157,14 +1184,22 @@ export class CloudflareScopeHost {
1157
1184
  ...load,
1158
1185
  ...(opts?.expect ? { expect: opts.expect } : {}),
1159
1186
  ...(opts?.fence ? { fence: opts.fence } : {}),
1160
- });
1187
+ }, repair);
1161
1188
  if (out.refused === false) {
1162
- return { tables: out.tables ?? 0, ...(carry ? { switchedOff: out.switchedOff } : {}) };
1189
+ return {
1190
+ tables: out.tables ?? 0,
1191
+ ...(carry ? { switchedOff: out.switchedOff } : {}),
1192
+ // Only a load that ran the judged method can say the repair landed in it.
1193
+ ...(repair && out.rolesProjected === true ? { rolesProjected: true } : {}),
1194
+ };
1163
1195
  }
1164
1196
  if (out.refused === 'kept')
1165
1197
  throw substratError('conflict', KEPT_COPY_REFUSAL);
1166
1198
  if (out.refused === 'tenant')
1167
1199
  throw substratError('conflict', out.message);
1200
+ // #2531: the repair's role set, refused on the seats the load wrote; the whole load rolled back.
1201
+ if (out.refused === 'roles')
1202
+ throw substratError('conflict', `the vertical's roles cannot be defined here: ${out.message}`);
1168
1203
  if (out.refused === 'lapsed')
1169
1204
  throw substratError('precondition_failed', COPY_RESTORE_FENCE_LAPSED);
1170
1205
  // A retry of a load that already committed (its answer was lost on the way back) is
@@ -1295,9 +1330,33 @@ export class CloudflareScopeHost {
1295
1330
  * safe; scope-level tuples (the restored grants) are never touched.
1296
1331
  */
1297
1332
  async projectRolesLocal(tenantId, scopeId, roles) {
1298
- // #2447: the role set is defined here, so it is judged here, before anything is projected.
1333
+ // #2447: the role set is defined here, so it is judged here, before anything is projected, and
1334
+ // (#2531) again inside the projection's unit, on the seats the scope holds as it writes.
1299
1335
  await this.refuseReservedRoleSet(roles, tenantId, scopeId);
1300
- 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' })), []));
1336
+ await this.projectJudged(tenantId, scopeId, projectedRoleRows(roles), []);
1337
+ }
1338
+ /**
1339
+ * #2531: what this host's own modules reserve and declare, for a unit that judges a role set in the
1340
+ * scope (`RoleJudge`); the scope class's are joined there.
1341
+ */
1342
+ roleJudge() {
1343
+ return { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
1344
+ }
1345
+ /**
1346
+ * #2531: `applyProjectionReply` with the role set judged in its unit (`applyJudgedProjectionReply`).
1347
+ * A refusal there answers with the staff-facing prefix `refuseReservedRoleSet` gives a pre-check's.
1348
+ * A ScopeDO from before the judged method refuses the call itself, with nothing written.
1349
+ */
1350
+ async projectJudged(tenantId, scopeId, ...rest) {
1351
+ try {
1352
+ return unwrapReply(await this.scopeStub(scopeId).applyJudgedProjectionReply(this.roleJudge(), tenantId, ...rest));
1353
+ }
1354
+ catch (e) {
1355
+ if (errorCodeOf(e) === 'forbidden') {
1356
+ throw substratError('forbidden', `tenant ${tenantId}, scope ${scopeId}: the vertical's roles cannot be defined here: ${e.message}`);
1357
+ }
1358
+ throw e;
1359
+ }
1301
1360
  }
1302
1361
  /**
1303
1362
  * Wipe one scope DO's storage in THIS deployment — the reap half of an orchestrated
@@ -1333,39 +1392,39 @@ export class CloudflareScopeHost {
1333
1392
  }
1334
1393
  /** Facet this host's own scope's outbox (#1239) — the vertical-host read. */
1335
1394
  async facetEventsLocal(scopeId, input) {
1336
- return this.scopeStub(scopeId).facetEvents(input);
1395
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).facetEventsIfHeld(input));
1337
1396
  }
1338
1397
  /** What one event set off (#1237) on this host's own scope — the vertical-host read. */
1339
1398
  async eventEffectsLocal(scopeId, input) {
1340
- return this.scopeStub(scopeId).eventEffects(input);
1399
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventEffectsIfHeld(input));
1341
1400
  }
1342
1401
  /** Everything one call emitted (#1237) on this host's own scope — the vertical-host read. */
1343
1402
  async invocationEventsLocal(scopeId, input) {
1344
- return this.scopeStub(scopeId).invocationEvents(input);
1403
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).invocationEventsIfHeld(input));
1345
1404
  }
1346
1405
  /** Every delivery that gave up (#1525) on this host's own scope — the vertical-host read. */
1347
1406
  async deadLettersLocal(scopeId, input) {
1348
- return this.scopeStub(scopeId).deadLetters(input);
1407
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).deadLettersIfHeld(input));
1349
1408
  }
1350
1409
  /** One entity's lifecycle replayed (#1744) on this host's own scope — the vertical-host read. */
1351
1410
  async lifecycleFlowLocal(scopeId, input) {
1352
- return this.scopeStub(scopeId).lifecycleFlow(input);
1411
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).lifecycleFlowIfHeld(input));
1353
1412
  }
1354
1413
  /** Business volumes per bucket (#1750) on this host's own scope — the vertical-host read. */
1355
1414
  async operationSeriesLocal(scopeId, input) {
1356
- return this.scopeStub(scopeId).operationSeries(input);
1415
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).operationSeriesIfHeld(input));
1357
1416
  }
1358
1417
  /** One event's causal chain (#1237) on this host's own scope — the vertical-host read. */
1359
1418
  async eventCauseLocal(scopeId, input) {
1360
- return this.scopeStub(scopeId).eventCause(input);
1419
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).eventCauseIfHeld(input));
1361
1420
  }
1362
1421
  /** One record's event history (#1235) on this host's own scope — the vertical-host read. */
1363
1422
  async entityHistoryLocal(scopeId, input) {
1364
- return this.scopeStub(scopeId).entityHistory(input);
1423
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).entityHistoryIfHeld(input));
1365
1424
  }
1366
1425
  /** When this host's own scope applied each migration (#1236) — the vertical-host read. */
1367
1426
  async appliedMigrationsLocal(scopeId) {
1368
- return this.scopeStub(scopeId).appliedMigrations();
1427
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).appliedMigrationsIfHeld());
1369
1428
  }
1370
1429
  /**
1371
1430
  * This host's own scope's database size in bytes (#1524), the vertical-host read. A scope this
@@ -1418,7 +1477,8 @@ export class CloudflareScopeHost {
1418
1477
  * so a caller cannot ask this side for more than the other would.
1419
1478
  */
1420
1479
  async undrainedEventsLocal(scopeId, limit) {
1421
- return undrainedEventsOf(await this.scopeStub(scopeId).undrainedEventsRead(Math.min(Math.max(limit, 1), 1000)));
1480
+ const bounded = Math.min(Math.max(limit, 1), 1000);
1481
+ return undrainedEventsOf(heldOrRefused(scopeId, await this.scopeStub(scopeId).undrainedEventsReadIfHeld(bounded)));
1422
1482
  }
1423
1483
  /**
1424
1484
  * Stamp `drained_at` on this host's own scope (#1334) — the delegation's other half.
@@ -1444,7 +1504,7 @@ export class CloudflareScopeHost {
1444
1504
  * platform's `redrainEvents` is the door, and a count egresses nothing to record.
1445
1505
  */
1446
1506
  async redrainCountLocal(scopeId, drainedBefore) {
1447
- return this.scopeStub(scopeId).redrainCount(drainedBefore);
1507
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).redrainCountIfHeld(drainedBefore));
1448
1508
  }
1449
1509
  /**
1450
1510
  * The PITR bookmarks one scope recorded before its migration passes (#286) — the
@@ -1452,7 +1512,7 @@ export class CloudflareScopeHost {
1452
1512
  * `/internal/bookmarks`; the control plane is the gate and the auditor.
1453
1513
  */
1454
1514
  async migrationBookmarksLocal(scopeId) {
1455
- return this.scopeStub(scopeId).migrationBookmarks();
1515
+ return heldOrRefused(scopeId, await this.scopeStub(scopeId).migrationBookmarksIfHeld());
1456
1516
  }
1457
1517
  /**
1458
1518
  * Rewind one scope to a pre-migration bookmark (#286's backout) — schema AND data,
@@ -1940,19 +2000,28 @@ export class CloudflareScopeHost {
1940
2000
  throw new Error(`connector attachment ${verb} is not delegated (#574) — upload and open are the ` +
1941
2001
  `verbs that cross the /internal seam to the serving deployment`);
1942
2002
  };
2003
+ const openArgs = (attachmentId) => ({
2004
+ connectionId,
2005
+ tenantId: tenant,
2006
+ scopeId,
2007
+ vertical,
2008
+ attachmentId,
2009
+ // #726 remedy B: the delivery, resolved at the far end against the
2010
+ // deployment's own outbox. Absent outside a dispatch, where the ordinary
2011
+ // permission check still applies.
2012
+ eventId: forEvent?.eventId,
2013
+ });
1943
2014
  return {
1944
2015
  upload: (upload) => delegation.uploadAttachment({ connectionId, tenantId: tenant, scopeId, vertical, upload }),
1945
- open: (attachmentId) => delegation.openAttachment({
1946
- connectionId,
1947
- tenantId: tenant,
1948
- scopeId,
1949
- vertical,
1950
- attachmentId,
1951
- // #726 remedy B: the delivery, resolved at the far end against the
1952
- // deployment's own outbox. Absent outside a dispatch, where the ordinary
1953
- // permission check still applies.
1954
- eventId: forEvent?.eventId,
1955
- }),
2016
+ open: (attachmentId) => delegation.openAttachment(openArgs(attachmentId)),
2017
+ // #2514: streamed over the seam when the embedder can. One that cannot reads the bytes whole,
2018
+ // as `open` does, and hands them on as a stream, so the verb still works and holds what it did.
2019
+ openStream: async (attachmentId) => {
2020
+ if (delegation.openAttachmentStream)
2021
+ return delegation.openAttachmentStream(openArgs(attachmentId));
2022
+ const opened = await delegation.openAttachment(openArgs(attachmentId));
2023
+ return opened && { record: opened.record, body: bytesStream(opened.body), contentType: opened.contentType };
2024
+ },
1956
2025
  list: notDelegated('list'),
1957
2026
  remove: notDelegated('remove'),
1958
2027
  // #1575: undelegated for `list`'s reason — a connector picks a document by id.
@@ -1999,6 +2068,10 @@ export class CloudflareScopeHost {
1999
2068
  const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
2000
2069
  return record ? this.openAttachmentBytes(store, scopeId, record) : null;
2001
2070
  },
2071
+ openStream: async (attachmentId) => {
2072
+ const record = unwrapReply(await stub.capabilityAttachmentOpen(attachmentId, hash, tenantId, scopeId));
2073
+ return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
2074
+ },
2002
2075
  remove: async (attachmentId) => unwrapReply(await stub.capabilityAttachmentRefuseWrite(hash, tenantId, scopeId, 'attachments.remove', {
2003
2076
  attachmentId,
2004
2077
  })),
@@ -2018,6 +2091,8 @@ export class CloudflareScopeHost {
2018
2091
  const asPrincipalId = ('principal' in subject
2019
2092
  ? subject.principal
2020
2093
  : subject.connectionId);
2094
+ // The read gate, asked in the DO before the blob store is touched: a refused read never reaches it.
2095
+ const authorizeRead = async (attachmentId) => unwrapReply(await stub.attachmentAuthorizeReply(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId));
2021
2096
  return {
2022
2097
  upload: async (input) => {
2023
2098
  const id = ulid();
@@ -2044,9 +2119,14 @@ export class CloudflareScopeHost {
2044
2119
  },
2045
2120
  list: async (entity) => unwrapReply(await stub.attachmentListReply(entity, asPrincipalId, tenantId, scopeId, connectionId)),
2046
2121
  open: async (attachmentId) => {
2047
- const record = unwrapReply(await stub.attachmentAuthorizeReply(attachmentId, 'read', asPrincipalId, tenantId, scopeId, connectionId, forEvent?.eventId));
2122
+ const record = await authorizeRead(attachmentId);
2048
2123
  return record ? this.openAttachmentBytes(store, scopeId, record) : null;
2049
2124
  },
2125
+ // #2514: the same gate, then the bytes as R2 reads them, checked against the record as they pass.
2126
+ openStream: async (attachmentId) => {
2127
+ const record = await authorizeRead(attachmentId);
2128
+ return record ? openAttachmentByteStream(store, attachmentBlobKey(scopeId, record.id), record, workerdSha256) : null;
2129
+ },
2050
2130
  remove: async (attachmentId) => {
2051
2131
  const removed = unwrapReply(await stub.attachmentRemoveReply(attachmentId, asPrincipalId, tenantId, scopeId, connectionId));
2052
2132
  if (removed)
@@ -2080,7 +2160,17 @@ export class CloudflareScopeHost {
2080
2160
  return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
2081
2161
  }
2082
2162
  async importScope(actor, input, dump) {
2163
+ await this.importScopeFrom(actor, input, dump, true);
2164
+ }
2165
+ /**
2166
+ * `importScope`, with its role judgement (#2555) when `judge`, made before the scope is created so a
2167
+ * refused import leaves nothing behind. A snapshot passes false: it is the platform's copy of a live
2168
+ * scope of this host, made at once, so its seats are the ones that scope holds now (K-55).
2169
+ */
2170
+ async importScopeFrom(actor, input, dump, judge) {
2083
2171
  this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
2172
+ if (judge)
2173
+ await this.refuseDumpRoles(input.tenantId, null, dump);
2084
2174
  // Create the destination scope (directory row + DO + lazy migrate); the DO then
2085
2175
  // replaces its provisioned schema with the dump wholesale (drop-then-replay), so
2086
2176
  // the end state is the dump, at the source's frontier. Provenance is stamped from
@@ -2105,6 +2195,8 @@ export class CloudflareScopeHost {
2105
2195
  if (!existing)
2106
2196
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2107
2197
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2198
+ // #2555: judged before the fence, so a refused restore moves nothing.
2199
+ await this.refuseDumpRoles(tenantId, scopeId, dump);
2108
2200
  // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2109
2201
  await this.fenceOrphanSweep(scopeId, tenantId);
2110
2202
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
@@ -2171,7 +2263,7 @@ export class CloudflareScopeHost {
2171
2263
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
2172
2264
  const dump = await this.admin.exportScope(actor, tenantId, scopeId);
2173
2265
  const snapshotId = ulid();
2174
- await this.importScope(actor, {
2266
+ await this.importScopeFrom(actor, {
2175
2267
  tenantId,
2176
2268
  scopeId: snapshotId,
2177
2269
  kind: opts?.kind ?? 'archive',
@@ -2180,7 +2272,7 @@ export class CloudflareScopeHost {
2180
2272
  jurisdiction: source.jurisdiction,
2181
2273
  expiresAt: opts?.expiresAt,
2182
2274
  // forkedFrom/forkedAt are stamped from the dump by importScope.
2183
- }, dump);
2275
+ }, dump, false);
2184
2276
  // Bind the snapshot to the source's current version so it is a runnable copy at the
2185
2277
  // same frontier (a fresh bind, so it never re-triggers the snapshot path).
2186
2278
  if (source.verticalVersionId) {
@@ -3713,7 +3805,9 @@ export class CloudflareScopeHost {
3713
3805
  await this.writeScopeTuple(node.scopeId, subject, `granted:${permission}`, `scope:${node.scopeId}`, expiresAt ?? null);
3714
3806
  }
3715
3807
  else {
3716
- await this.cp.writeTenantTuple(node.tenantId, subject, `granted:${permission}`, `tenant:${node.tenantId}`, expiresAt ?? null);
3808
+ // #2526: a tenant-level grant is a directory tuple, written by the ControlPlaneDO in the
3809
+ // unit that writes its admin row — never through here.
3810
+ throw new Error('writeGrant: a tenant-level grant is written with its row in the ControlPlaneDO');
3717
3811
  }
3718
3812
  };
3719
3813
  /**
@@ -4245,11 +4339,11 @@ export class CloudflareScopeHost {
4245
4339
  // `defineRoleOrRefusal`, not the legacy `defineRole` an older coordinator reads. A ControlPlaneDO from
4246
4340
  // before #2447, met in a deploy's restart window, has no such method, so the call fails loudly rather
4247
4341
  // than being misread.
4248
- const defined = await this.cp.defineRoleOrRefusal(tenantId, parsed, context.reservation, context.seatedInScopes);
4342
+ // #2526: through `reply`, with the row, which the DO writes in the definition's unit, its
4343
+ // `before` the role it read there. A DO from before #2526 does not list the method, and refuses.
4344
+ const defined = await this.directory('defineRoleOrRefusal', tenantId, parsed, context.reservation, context.seatedInScopes, this.adminEntry(actor, 'defineRole', { tenantId }, null, parsed));
4249
4345
  if ('refused' in defined)
4250
4346
  throw substratError('forbidden', defined.refused);
4251
- const before = defined.before;
4252
- await this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
4253
4347
  await this.fanOut(tenantId); // role definitions are projected into the tenant's scopes
4254
4348
  },
4255
4349
  listRoles: async (actor, filter) => {
@@ -4274,29 +4368,22 @@ export class CloudflareScopeHost {
4274
4368
  assignRole: async (actor, assignment) => {
4275
4369
  // #2430: the platform's door — a role mixing reserved and other keys is refused (K-55).
4276
4370
  // At a scope node, read where the checker reads the role, against the scope class's own
4277
- // reservations (it answers null when it has none). At the tenant node, against this host's.
4278
- {
4279
- const { tenantId, scopeId } = assignment.node;
4280
- const refusal = scopeId
4281
- ? await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform')
4282
- : this.reservedKeys.size > 0
4283
- ? await this.cp.getRole(tenantId, assignment.roleKey).then((role) => role && reservedRoleRefusal(role, this.reservedKeys, 'platform'))
4284
- : null;
4285
- if (refusal)
4286
- throw substratError('forbidden', refusal);
4287
- }
4288
- const subject = `principal:${assignment.principalId}`;
4289
- if (assignment.node.scopeId) {
4290
- await this.writeScopeTuple(assignment.node.scopeId, subject, `role:${assignment.roleKey}`, `scope:${assignment.node.scopeId}`, null);
4291
- }
4292
- else {
4293
- await this.cp.writeTenantTuple(assignment.node.tenantId, subject, `role:${assignment.roleKey}`, `tenant:${assignment.node.tenantId}`, null);
4371
+ // reservations (it answers null when it has none). At the tenant node, against this host's,
4372
+ // in the ControlPlaneDO's unit that writes the tuple and its row (#2526).
4373
+ const { tenantId, scopeId } = assignment.node;
4374
+ if (!scopeId) {
4375
+ const assigned = await this.directory('assignTenantRole', tenantId, assignment.principalId, assignment.roleKey, [...this.reservedKeys], this.adminEntry(actor, 'assignRole', { tenantId, scopeId }, null, assignment));
4376
+ if (assigned.refused)
4377
+ throw substratError('forbidden', assigned.refused);
4378
+ // A tenant-level assignment changes the projected set and must fan out.
4379
+ await this.fanOut(tenantId);
4380
+ return;
4294
4381
  }
4295
- await this.recordAdmin(actor, 'assignRole', { tenantId: assignment.node.tenantId, scopeId: assignment.node.scopeId }, null, assignment);
4296
- // A scope-level assignment writes a scope tuple (already local); only a
4297
- // tenant-level one changes the projected set and must fan out.
4298
- if (!assignment.node.scopeId)
4299
- await this.fanOut(assignment.node.tenantId);
4382
+ const refusal = await this.scopeStub(scopeId).reservedRoleRefusalFor(tenantId, assignment.roleKey, 'platform');
4383
+ if (refusal)
4384
+ throw substratError('forbidden', refusal);
4385
+ await this.writeScopeTuple(scopeId, `principal:${assignment.principalId}`, `role:${assignment.roleKey}`, `scope:${scopeId}`, null);
4386
+ await this.recordAdmin(actor, 'assignRole', { tenantId, scopeId }, null, assignment);
4300
4387
  },
4301
4388
  unassignRole: async (actor, assignment) => {
4302
4389
  // Tombstone (K-21) — the checker skips revoked rows. A no-op returns false so
@@ -4323,12 +4410,18 @@ export class CloudflareScopeHost {
4323
4410
  // to Z text (#963), and liveness here is a lexicographic `expires_at > ?`.
4324
4411
  const grant = capabilityGrant.parse(raw);
4325
4412
  await this.refuseReservedGrant([grant.permission], grant.node.scopeId); // #2430
4326
- await writeGrant(`principal:${grant.principalId}`, grant.permission, grant.node, grant.entity, grant.expiresAt);
4327
- await this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
4328
- // Tenant-level grant → changes the projected set. Scope-level + entity
4329
- // grants write scope tuples (already local), so they need no fan-out.
4330
- if (!grant.node.scopeId)
4413
+ const subject = `principal:${grant.principalId}`;
4414
+ const target = { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId };
4415
+ if (!grant.node.scopeId && !grant.entity) {
4416
+ // #2526: a directory tuple, written with its row in one ControlPlaneDO unit. Tenant-level
4417
+ // → it changes the projected set, so it fans out.
4418
+ await this.directory('writeTenantTuple', grant.node.tenantId, subject, `granted:${grant.permission}`, `tenant:${grant.node.tenantId}`, grant.expiresAt ?? null, this.adminEntry(actor, 'grant', target, null, grant));
4331
4419
  await this.fanOut(grant.node.tenantId);
4420
+ return;
4421
+ }
4422
+ // Scope-level and entity grants write scope tuples (already local): no fan-out.
4423
+ await writeGrant(subject, grant.permission, grant.node, grant.entity, grant.expiresAt);
4424
+ await this.recordAdmin(actor, 'grant', target, null, grant);
4332
4425
  },
4333
4426
  grantEntityShape: async (actor, grant) => {
4334
4427
  await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
@@ -4378,27 +4471,37 @@ export class CloudflareScopeHost {
4378
4471
  // fails below is still gathered by the next provision/reconcile — the
4379
4472
  // repair channel — instead of vanishing. The tuple stays the only thing
4380
4473
  // the permission checker reads; this row is what the platform gathers
4381
- // from so scopes provisioned AFTER the grant receive it too.
4382
- await this.cp.recordConnectionGrant({
4474
+ // from so scopes provisioned AFTER the grant receive it too. #2526: the
4475
+ // record, the checks above made again, the admin row and — at the tenant
4476
+ // node — the tuple are one ControlPlaneDO unit.
4477
+ const { vertical } = await this.directory('grantConnectionInDirectory', {
4383
4478
  connectionId: grant.connectionId,
4384
4479
  tenantId: grant.node.tenantId,
4385
- vertical: conn.vertical,
4386
4480
  permission: grant.permission,
4387
4481
  scopeId: grant.node.scopeId ?? null,
4388
4482
  expiresAt: grant.expiresAt ?? null,
4389
4483
  grantedBy: grant.grantedBy,
4390
4484
  grantedAt: new Date().toISOString(),
4391
- });
4485
+ }, this.adminEntry(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
4486
+ connectionId: grant.connectionId,
4487
+ provider: conn.provider,
4488
+ permission: grant.permission,
4489
+ node: grant.node,
4490
+ }));
4491
+ if (!grant.node.scopeId) {
4492
+ // A tenant tuple changes the projected set, as its siblings' do: it fans out.
4493
+ await this.fanOut(grant.node.tenantId);
4494
+ return;
4495
+ }
4392
4496
  // #574: a scope-level grant is a SCOPE tuple, and the scope's DO lives in the
4393
4497
  // deployment serving it — for the shared control plane that is the vertical's
4394
- // dispatch script, so the tuple write rides the delegation seam. Tenant-level
4395
- // grants stay directory-side either way.
4396
- if (this.connectorDelegation && grant.node.scopeId) {
4498
+ // dispatch script, so the tuple write rides the delegation seam.
4499
+ if (this.connectorDelegation) {
4397
4500
  await this.connectorDelegation.grant({
4398
4501
  connectionId: grant.connectionId,
4399
4502
  tenantId: grant.node.tenantId,
4400
4503
  scopeId: grant.node.scopeId,
4401
- vertical: conn.vertical,
4504
+ vertical,
4402
4505
  permission: grant.permission,
4403
4506
  expiresAt: grant.expiresAt,
4404
4507
  });
@@ -4406,17 +4509,13 @@ export class CloudflareScopeHost {
4406
4509
  else {
4407
4510
  await writeGrant(subjectRef({ kind: 'connection', id: grant.connectionId }), grant.permission, grant.node, undefined, grant.expiresAt);
4408
4511
  }
4409
- await this.recordAdmin(actor, 'grantToConnection', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId, vertical: conn.vertical }, null, {
4410
- connectionId: grant.connectionId,
4411
- provider: conn.provider,
4412
- permission: grant.permission,
4413
- node: grant.node,
4414
- });
4415
4512
  },
4416
4513
  grantToSystem: async (actor, raw) => {
4417
4514
  // The scheduler's grant (#383) — mirror of grantToConnection. Narrow: one
4418
4515
  // module, one permission; tombstones on revoke; shows in the permission diff.
4419
4516
  const grant = systemGrant.parse(raw);
4517
+ const target = { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId };
4518
+ const after = { moduleId: grant.moduleId, permission: grant.permission, node: grant.node };
4420
4519
  if (grant.node.scopeId) {
4421
4520
  // #1666: refused while the module is switched off on this scope — checked and
4422
4521
  // written in one DO unit. Restore is the lever; a grant is not.
@@ -4424,18 +4523,17 @@ export class CloudflareScopeHost {
4424
4523
  if (!written) {
4425
4524
  throw substratError('conflict', systemSwitchedOffMessage(grant.moduleId, grant.node.scopeId));
4426
4525
  }
4526
+ await this.recordAdmin(actor, 'grantToSystem', target, null, after);
4527
+ return;
4427
4528
  }
4428
- else {
4429
- // #1743: refused while the record holds the module off on any scope — checked and
4430
- // written in one control-plane call, where the record and the tenant tuple both live.
4431
- const off = await this.cp.writeTenantSystemGrant(grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null);
4432
- if (off.length > 0) {
4433
- throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
4434
- }
4529
+ // #1743: refused while the record holds the module off on any scope — checked and
4530
+ // written in one control-plane call, where the record and the tenant tuple both live,
4531
+ // and (#2526) the admin row with them.
4532
+ const off = await this.directory('writeTenantSystemGrant', grant.node.tenantId, grant.moduleId, `granted:${grant.permission}`, grant.expiresAt ?? null, this.adminEntry(actor, 'grantToSystem', target, null, after));
4533
+ if (off.length > 0) {
4534
+ throw substratError('conflict', tenantSystemSwitchedOffMessage(grant.moduleId, off));
4435
4535
  }
4436
- await this.recordAdmin(actor, 'grantToSystem', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, { moduleId: grant.moduleId, permission: grant.permission, node: grant.node });
4437
- if (!grant.node.scopeId)
4438
- await this.fanOut(grant.node.tenantId);
4536
+ await this.fanOut(grant.node.tenantId);
4439
4537
  },
4440
4538
  // #1666: the schedule kill switch and its lever back — `system-switch.ts` is the
4441
4539
  // whole rule, shared with the pure adapter; this is the directory check, the reach
@@ -4523,10 +4621,16 @@ export class CloudflareScopeHost {
4523
4621
  // applied, resolves for nobody, and still shows up in the permission diff.
4524
4622
  await requireOrg(node.tenantId, orgId);
4525
4623
  await this.refuseReservedGrant([permission], node.scopeId); // #2430: an org's members are people
4526
- await writeGrant(`org:${orgId}`, permission, node, entity);
4527
- await this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
4528
- if (!node.scopeId)
4624
+ const target = { tenantId: node.tenantId, scopeId: node.scopeId };
4625
+ const after = { orgId, permission, node, entity };
4626
+ if (!node.scopeId && !entity) {
4627
+ // #2526: the org checked again, the tuple and its row, in one ControlPlaneDO unit.
4628
+ await this.directory('writeOrgTenantTuple', node.tenantId, orgId, `org:${orgId}`, `granted:${permission}`, `tenant:${node.tenantId}`, null, this.adminEntry(actor, 'grantToOrg', target, null, after));
4529
4629
  await this.fanOut(node.tenantId);
4630
+ return;
4631
+ }
4632
+ await writeGrant(`org:${orgId}`, permission, node, entity);
4633
+ await this.recordAdmin(actor, 'grantToOrg', target, null, after);
4530
4634
  },
4531
4635
  // -- vertical + version registry (#31) ---------------------------------
4532
4636
  // -- the hostname map (K-26) -------------------------------------------
@@ -4705,7 +4809,9 @@ export class CloudflareScopeHost {
4705
4809
  // Lands PENDING — a push is not a deploy — except for a PRIVATE vertical
4706
4810
  // (tenant-owned, not listed), whose blast radius is its own tenant: there the
4707
4811
  // sandbox contract is the gate and the version self-admits, noted so the
4708
- // publish seam can tell a staff vouch from this shortcut.
4812
+ // publish seam can tell a staff vouch from this shortcut. The DO judges it again on the
4813
+ // vertical as the insert's unit reads it, and that is what the row and the version carry
4814
+ // (#2528); this read answers an older DO, which takes what it is sent.
4709
4815
  const selfAdmits = owning.owner_tenant !== null && !owning.listed;
4710
4816
  // The manifest is retained for the serving upload (#286), not audited — a whole
4711
4817
  // manifest per publish would drown the admin log in bundle metadata.
@@ -4751,67 +4857,33 @@ export class CloudflareScopeHost {
4751
4857
  await this.recordAccess(actor, 'getVersion', {}, { versionId, verticalSlug }, hit ? 1 : 0);
4752
4858
  return hit ? mapVersion(hit) : undefined;
4753
4859
  },
4860
+ // #2528: each vertical write below is one DO call that reads the row's `before`, and runs its
4861
+ // guard, in the unit that writes; it refuses an unknown vertical with not_found.
4754
4862
  setVerticalListed: async (actor, slug, listed) => {
4755
- const existing = await this.cp.readVertical(slug);
4756
- if (!existing)
4757
- throw substratError('not_found', `unknown vertical '${slug}'`);
4758
- // Listing is the moment other tenants start trusting this code, so the
4759
- // version they would install must carry a real staff vouch — an auto-admitted
4760
- // prod version has never been read by anyone but its author.
4761
- if (listed) {
4762
- const prod = await this.cp.readChannel(slug, 'prod');
4763
- const prodVersion = prod ? await this.cp.readVersion(prod.version_id) : undefined;
4764
- if (prodVersion?.admission_note === AUTO_ADMISSION_NOTE) {
4765
- throw substratError('conflict', `vertical '${slug}' prod version ${prodVersion.id} is auto-admitted (private self-serve) — ` +
4766
- `a staff admit must vouch for it before listing`);
4767
- }
4768
- }
4769
- await this.cp.updateVerticalListed(slug, listed ? 1 : 0, // also resolves any pending request
4770
- this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, { listed: !!existing.listed }, { listed }));
4863
+ // Listing is the moment other tenants start trusting this code, so the version they
4864
+ // would install must carry a real staff vouch — an auto-admitted prod version has never
4865
+ // been read by anyone but its author. The DO refuses one; it also resolves any pending
4866
+ // publish request.
4867
+ await this.directory('updateVerticalListed', slug, listed ? 1 : 0, this.adminEntry(actor, 'setVerticalListed', { tenantId: null }, null, { listed }));
4771
4868
  },
4772
4869
  requestPublish: async (actor, slug) => {
4773
- const existing = await this.cp.readVertical(slug);
4774
- if (!existing)
4775
- throw substratError('not_found', `unknown vertical '${slug}'`);
4776
- await this.cp.updateVerticalPublishRequest(slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
4870
+ await this.directory('updateVerticalPublishRequest', slug, new Date().toISOString(), this.adminEntry(actor, 'requestPublish', { tenantId: null }, null, { slug }));
4777
4871
  },
4778
4872
  setVerticalInstallsBlocked: async (actor, slug, blocked) => {
4779
- const existing = await this.cp.readVertical(slug);
4780
- if (!existing)
4781
- throw substratError('not_found', `unknown vertical '${slug}'`);
4782
- await this.cp.updateVerticalInstallsBlocked(slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, { installsBlocked: !!existing.installs_blocked }, { installsBlocked: blocked }));
4873
+ await this.directory('updateVerticalInstallsBlocked', slug, blocked ? 1 : 0, this.adminEntry(actor, 'setVerticalInstallsBlocked', { tenantId: null }, null, { installsBlocked: blocked }));
4783
4874
  },
4784
4875
  setVerticalTenantProvisioner: async (actor, slug, granted) => {
4785
- const existing = await this.cp.readVertical(slug);
4786
- if (!existing)
4787
- throw substratError('not_found', `unknown vertical '${slug}'`);
4788
- await this.cp.updateVerticalTenantProvisioner(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, { tenantProvisioner: !!existing.tenant_provisioner }, { tenantProvisioner: granted }));
4876
+ await this.directory('updateVerticalTenantProvisioner', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalTenantProvisioner', { tenantId: null }, null, { tenantProvisioner: granted }));
4789
4877
  },
4790
4878
  setVerticalEmailSender: async (actor, slug, granted) => {
4791
- const existing = await this.cp.readVertical(slug);
4792
- if (!existing)
4793
- throw substratError('not_found', `unknown vertical '${slug}'`);
4794
- await this.cp.updateVerticalEmailSender(slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, { emailSender: !!existing.email_sender }, { emailSender: granted }));
4879
+ await this.directory('updateVerticalEmailSender', slug, granted ? 1 : 0, this.adminEntry(actor, 'setVerticalEmailSender', { tenantId: null }, null, { emailSender: granted }));
4795
4880
  },
4796
4881
  deleteVertical: async (actor, slug) => {
4797
- const existing = await this.cp.readVertical(slug);
4798
- if (!existing)
4799
- throw substratError('not_found', `unknown vertical '${slug}'`);
4800
- // Refuse while any restorable scope is bound: a deleted registry row would strand
4801
- // those scopes' version pins and routing. An `archived` scope (a deleted app) still
4802
- // blocks — unarchive can bring it back — but the refusal names reap/restore, not
4803
- // "delete", because the app itself is already gone. `reaped` is terminal history and
4804
- // never blocks. Deployed dispatch scripts are NOT reaped here — they become orphans
4805
- // for the cleanup script (#248).
4806
- const bound = await this.cp.countScopesForVertical(slug);
4807
- if (bound.live > 0) {
4808
- throw substratError('conflict', `vertical '${slug}' still backs ${bound.live} scope(s) — delete or rebind them first`);
4809
- }
4810
- if (bound.archived > 0) {
4811
- throw substratError('conflict', `vertical '${slug}' still backs ${bound.archived} archived scope(s) — reap or restore them first`);
4812
- }
4813
- // #2501: the period's `until` is read here, on the clock its `since` was.
4814
- await this.cp.deleteVertical(slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, { slug, source: existing.source, ownerTenant: existing.owner_tenant }, null), verticalOwnerNow());
4882
+ // Refused while any restorable scope is bound (the DO counts them in the delete's unit).
4883
+ // `reaped` is terminal history and never blocks. Deployed dispatch scripts are NOT reaped
4884
+ // here — they become orphans for the cleanup script (#248). #2501: the period's `until`
4885
+ // is read here, on the clock its `since` was.
4886
+ await this.directory('deleteVertical', slug, this.adminEntry(actor, 'deleteVertical', { tenantId: null }, null, null), verticalOwnerNow());
4815
4887
  },
4816
4888
  listVerticalOwners: async (actor, slug) => {
4817
4889
  const periods = (await this.cp.listVerticalOwners(slug)).map((p) => verticalOwnerPeriod.parse(p));
@@ -4819,21 +4891,9 @@ export class CloudflareScopeHost {
4819
4891
  return periods;
4820
4892
  },
4821
4893
  admitVersion: async (actor, versionId) => {
4822
- const v = await this.cp.readVersion(versionId);
4823
- if (!v)
4824
- throw substratError('not_found', `unknown version ${versionId}`);
4825
- if (v.admission === 'admitted') {
4826
- // Idempotent — except an AUTO-admitted version, which this upgrades to a
4827
- // manual vouch by clearing the note (what the publish seam requires).
4828
- if (v.admission_note !== AUTO_ADMISSION_NOTE)
4829
- return;
4830
- await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission, note: v.admission_note }, { admission: 'admitted', note: null }));
4831
- return;
4832
- }
4833
- if (v.admission === 'rejected') {
4834
- throw substratError('conflict', `version ${versionId} was rejected — publish a new one`);
4835
- }
4836
- await this.cp.setAdmission(versionId, 'admitted', null, this.adminEntry(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, { admission: 'admitted' }));
4894
+ // Judged in the DO's unit (#2528): idempotent on an admitted version, except an AUTO-admitted
4895
+ // one, which it upgrades to a manual vouch; a rejected one is refused. The DO fills the row.
4896
+ await this.directory('admitVersion', versionId, this.adminEntry(actor, 'admitVersion', { tenantId: null }, null, null));
4837
4897
  },
4838
4898
  promotionImpact: async (actor, verticalSlug, channel, versionId) => {
4839
4899
  const incoming = await this.cp.readVersion(versionId);
@@ -4849,15 +4909,8 @@ export class CloudflareScopeHost {
4849
4909
  return breaks;
4850
4910
  },
4851
4911
  rejectVersion: async (actor, versionId, note) => {
4852
- const v = await this.cp.readVersion(versionId);
4853
- if (!v)
4854
- throw substratError('not_found', `unknown version ${versionId}`);
4855
- if (v.admission === 'admitted') {
4856
- throw substratError('conflict', `version ${versionId} is already admitted — it may be bound`);
4857
- }
4858
- if (v.admission === 'rejected')
4859
- return;
4860
- await this.cp.setAdmission(versionId, 'rejected', note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, { admission: 'rejected', note }));
4912
+ // Judged in the DO's unit (#2528): an admitted version is refused, a rejected one is a no-op.
4913
+ await this.directory('rejectVersion', versionId, note, this.adminEntry(actor, 'rejectVersion', { tenantId: null }, null, { admission: 'rejected', note }));
4861
4914
  },
4862
4915
  promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
4863
4916
  const incoming = await this.cp.readVersion(versionId);
@@ -5134,14 +5187,16 @@ export class CloudflareScopeHost {
5134
5187
  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) : []);
5135
5188
  }
5136
5189
  }
5137
- await this.cp.setVerticalServing(verticalSlug, {
5190
+ // #2528: a compare-and-set on the serving ref and version the gate above judged, across
5191
+ // awaits another serve can run in. The DO fills the row's `before` from the state it moves.
5192
+ const moved = await this.directory('setVerticalServing', verticalSlug, {
5138
5193
  ref: parsed.ref,
5139
5194
  versionId: parsed.versionId,
5140
5195
  doClassesJson: JSON.stringify(parsed.doClasses),
5141
5196
  migrationTag: parsed.migrationTag,
5142
- }, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, r.serving_ref
5143
- ? { ref: r.serving_ref, versionId: r.serving_version_id }
5144
- : null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }));
5197
+ }, 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 });
5198
+ if (!moved)
5199
+ throw servingRaceRefusal(verticalSlug);
5145
5200
  },
5146
5201
  versionManifest: async (actor, verticalSlug, versionId) => {
5147
5202
  const v = await this.cp.readVersion(versionId);
@@ -5161,7 +5216,10 @@ export class CloudflareScopeHost {
5161
5216
  },
5162
5217
  recordWireAuth: async (actor, input) => {
5163
5218
  const row = deploymentWireAuth.parse(input);
5164
- 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 }));
5219
+ 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 }));
5220
+ // Thrown here, not in the directory's object, so the code survives the RPC.
5221
+ if (!written)
5222
+ throw substratError('not_found', `no capability record for '${row.deploymentRef}': vertical '${row.verticalSlug}' is not registered`);
5165
5223
  },
5166
5224
  listWireAuth: async (actor, input) => {
5167
5225
  // Judged here, before the call into the directory's object, so a refusal keeps its code; the
@@ -5262,10 +5320,8 @@ export class CloudflareScopeHost {
5262
5320
  },
5263
5321
  createOrg: async (actor, input) => {
5264
5322
  const parsed = createOrgInput.parse(input);
5265
- const created = await this.directory('createOrg', parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString());
5266
- if (!created)
5267
- return; // idempotent, and a no-op is not audited
5268
- await this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed);
5323
+ // #2526: the org and its row in one unit; an org that exists already writes neither.
5324
+ await this.directory('insertOrg', parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString(), this.adminEntry(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed));
5269
5325
  },
5270
5326
  listOrgs: async (actor, tenantId) => {
5271
5327
  const orgs = (await this.cp.listOrgs(tenantId)).map(mapOrg);
@@ -5278,10 +5334,9 @@ export class CloudflareScopeHost {
5278
5334
  return r ? mapOrg(r) : undefined;
5279
5335
  },
5280
5336
  addMember: async (actor, tenantId, principal, orgId, opts) => {
5281
- await requireOrg(tenantId, orgId);
5282
5337
  const expiresAt = opts?.expiresAt ?? null;
5283
- await this.cp.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt);
5284
- await this.recordAdmin(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt));
5338
+ // #2526: the org's check, the tuple and its row, in one ControlPlaneDO unit.
5339
+ await this.directory('writeOrgTenantTuple', tenantId, orgId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt, this.adminEntry(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt)));
5285
5340
  await this.fanOut(tenantId); // membership is a tenant-level tuple
5286
5341
  },
5287
5342
  applyMembership: async (actor, change) => {
@@ -5458,6 +5513,9 @@ export class CloudflareScopeHost {
5458
5513
  recordStoreDriverRebuildRun: async (actor, entry) => {
5459
5514
  await this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry);
5460
5515
  },
5516
+ recordWireWalk: async (actor, entry) => {
5517
+ await this.recordAdmin(actor, 'walkWireAuth', { tenantId: null }, null, entry);
5518
+ },
5461
5519
  listScopeTables: async (actor, tenantId, scopeId) => {
5462
5520
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
5463
5521
  // that does not resolve is unreachable, never another tenant's database.
@@ -7684,9 +7742,11 @@ export class CloudflareScopeHost {
7684
7742
  throw substratError('forbidden', `the owner's seat: ${ownerRefusal}`);
7685
7743
  const connections = await this.seatableConnectionGrants(input.tenantId, input.scopeId, input.connectionGrants);
7686
7744
  const services = await this.servicePrincipals?.(input.tenantId, input.scopeId);
7687
- const stub = this.scopeStub(input.scopeId);
7688
7745
  await this.migrateAndRecord(input.scopeId); // create the module tables (setMigrationState no-ops on a null CP)
7689
- const switchedOff = unwrapReply(await stub.applyProjectionReply(input.tenantId, input.roles.map((r) => ({ role_key: r.key, permissions: JSON.stringify(r.permissions), source: r.source, holder: r.holder === 'system' ? 'system' : 'person' })), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
7746
+ // #2531: judged again in the projection's own unit, on the roles and seats the scope holds as it
7747
+ // writes, so a seat made since the pre-check above is seen. The module migrations above have
7748
+ // already run when that refuses; they are this host's own setup, and the projection writes nothing.
7749
+ const switchedOff = await this.projectJudged(input.tenantId, input.scopeId, projectedRoleRows(input.roles), [], // no tenant-level tuples — a CP-less vertical grants at scope level only
7690
7750
  // Only PROJECT (and thereby switch on strict enforcement) when the platform actually
7691
7751
  // supplies entitlements. Omitting them leaves the scope un-projected and trusting-
7692
7752
  // upstream, so a vertical whose provision path predates #304 is never denied — it
@@ -7746,7 +7806,7 @@ export class CloudflareScopeHost {
7746
7806
  : undefined,
7747
7807
  // #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
7748
7808
  // same unit, on the scope this call provisions — the one the seat's tuples name.
7749
- carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`)));
7809
+ carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`));
7750
7810
  const shapes = input.entityGrants
7751
7811
  ? await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants)
7752
7812
  : null;
@@ -7825,12 +7885,36 @@ export class CloudflareScopeHost {
7825
7885
  * which replaces whatever the scope stores now, so what the set is judged against is the DUMP's
7826
7886
  * roles and seats for `dump.tenantId` (`dumpRoleState`), when the caller has the dump in hand. A
7827
7887
  * staged restore has not (its pages are already in the scope), so it is judged as a set alone
7828
- * here, and `projectRolesLocal` judges again after the import, against what the dump brought.
7888
+ * here. The load given the vertical's `roles` judges again in its own transaction, against what
7889
+ * the dump brought (#2531), and `projectRolesLocal` does in its unit where the load did not.
7829
7890
  */
7830
7891
  async roleSetRefusalLocal(scopeId, roles, dump) {
7831
7892
  const here = await this.scopeStub(scopeId).reservationHere();
7832
7893
  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);
7833
7894
  }
7895
+ /**
7896
+ * #2555: refuse loading `dump` into a scope of `tenantId` (`dumpRoleRefusal`): the tenant's roles as
7897
+ * the directory stores them now, against the dump's role rows and seats, with this host's
7898
+ * reservations joined with the scope class's. The class is read at `scopeId` when the scope exists
7899
+ * (a restore), else at a live scope of the tenant (an import, whose scope is not created yet: asking
7900
+ * its object would create storage for a scope a refusal leaves unmade).
7901
+ *
7902
+ * Two stated residuals. The directory is read here and the dump is loaded in the scope's own call,
7903
+ * so a role defined between the two is not judged against the dump's seats. And an import for a
7904
+ * tenant with no live scope reads no class, so it is judged with this host's reservations alone: the
7905
+ * seat rule holds without them, and a key only the class reserves is not counted.
7906
+ */
7907
+ async refuseDumpRoles(tenantId, scopeId, dump) {
7908
+ const classScope = scopeId ?? (await this.cp.listScopes({ tenantId, status: ['active', 'suspended'], limit: 1 }))[0]?.scope_id;
7909
+ const [rows, here] = await Promise.all([
7910
+ this.cp.listRoles({ tenantId }),
7911
+ classScope ? this.scopeStub(classScope).reservationHere() : { reserved: [], declared: [] },
7912
+ ]);
7913
+ const roles = rows.map((r) => ({ key: r.role_key, permissions: JSON.parse(r.permissions), ...(r.holder ? { holder: r.holder } : {}) }));
7914
+ const refusal = dumpRoleRefusal(roles, { reserved: new Set([...this.reservedKeys, ...here.reserved]), declared: new Set([...this.declaredKeys, ...here.declared]) }, dump, new Date().toISOString());
7915
+ if (refusal)
7916
+ throw substratError('forbidden', refusal);
7917
+ }
7834
7918
  /**
7835
7919
  * #2446: the roles `scopeId` stores for `tenantId` and the role keys anyone but a system principal
7836
7920
  * holds a live seat in there: what `refuseReservedRoleSet` judges a reconcile's role set against
@@ -7846,15 +7930,18 @@ export class CloudflareScopeHost {
7846
7930
  * and the roles the scope stores for the tenant now with their seats there. Answers the reserved
7847
7931
  * keys it judged with, for the seats the same call makes.
7848
7932
  *
7849
- * A stated residual: the scope state is read in one RPC (`roleStateHere`) and written in another
7850
- * (`applyProjectionReply`), so a seat made between the two is not seen. The window is one call
7851
- * wide, and a reconcile runs again every pass.
7933
+ * The scope state is read in one RPC (`roleStateHere`) and written in another, so this is the
7934
+ * first check, for its detailed wording; the write judges the set again inside its own unit
7935
+ * (`projectJudged`, #2531), where a seat made between the two is seen.
7852
7936
  */
7853
7937
  async refuseReservedRoleSet(roles, tenantId, scopeId) {
7854
7938
  const stub = this.scopeStub(scopeId);
7855
7939
  const [here, state] = await Promise.all([stub.reservationHere(), stub.roleStateHere(tenantId)]);
7856
7940
  const reserved = new Set([...this.reservedKeys, ...here.reserved]);
7857
- const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) }, { roles: state.roles, seated: new Set(state.seated) });
7941
+ const refusal = reservedRoleSetRefusal(roles, { reserved, declared: new Set([...this.declaredKeys, ...here.declared]) },
7942
+ // #2531: with the tombstones the scope keeps, so a system-held role a release dropped is judged
7943
+ // as the stored definition it was. A ScopeDO from before them answers none.
7944
+ { roles: state.roles, seated: new Set(state.seated), ...(state.dropped ? { dropped: state.dropped } : {}) });
7858
7945
  // Named by tenant and scope as well as role: a release push admitted can be refused here at every
7859
7946
  // scope where the role is seated, and staff find each one by this line (K-55).
7860
7947
  if (refusal)
@@ -8137,6 +8224,17 @@ export class CloudflareScopeHost {
8137
8224
  * so a caller falls back rather than failing a dispatch over a missing file.
8138
8225
  */
8139
8226
  async connectorAttachmentOpenLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
8227
+ return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).open(attachmentId);
8228
+ }
8229
+ /**
8230
+ * `connectorAttachmentOpenLocal`, with the bytes as a stream read from R2 as the platform reads
8231
+ * the response (#2514). The same gates, in the same order, before the bucket is touched.
8232
+ */
8233
+ async connectorAttachmentOpenStreamLocal(connectionId, tenantId, scopeId, attachmentId, eventId) {
8234
+ return (await this.connectorReadSurface(connectionId, tenantId, scopeId, eventId)).openStream(attachmentId);
8235
+ }
8236
+ /** The attachment surface the platform's reads as a connection go through, behind their gates. */
8237
+ async connectorReadSurface(connectionId, tenantId, scopeId, eventId) {
8140
8238
  // Same gate as the upload leg: a held scope's bytes are not read out of its bucket.
8141
8239
  await this.assertLive(tenantId, scopeId);
8142
8240
  await this.migrateAndRecord(scopeId);
@@ -8144,7 +8242,7 @@ export class CloudflareScopeHost {
8144
8242
  return this.buildAttachmentSurface({ connectionId }, tenantId, scopeId, store,
8145
8243
  // #726 remedy B: the platform named a delivery; this deployment decides what that
8146
8244
  // delivery reaches, from its own spine row. Absent ⇒ the ordinary grant check.
8147
- eventId === undefined ? undefined : { eventId }).open(attachmentId);
8245
+ eventId === undefined ? undefined : { eventId });
8148
8246
  }
8149
8247
  /**
8150
8248
  * The delivery half of `grantToConnection` for a scope served HERE (#574): write