@substrat-run/adapter-cloudflare 0.146.0 → 0.148.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,11 +1,12 @@
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, 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, copyMarkAudit, 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, 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, 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, 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, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
6
+ import { attachmentBlobKey, UPLOAD_LINK_WRONG_TARGET, 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, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
7
7
  import { COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
8
- import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased } from '@substrat-run/kernel';
8
+ import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf } from '@substrat-run/kernel';
9
+ import { PLATFORM_BINDING_PREFIX, scopeStoreBindingName, scopeStoreObjectName, STORE_CALL_URL, StoreError } from '@substrat-run/contracts';
9
10
  import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, liveInstant, encodeLiveWithin, liveWithinOf, } from './live-reads.js';
10
11
  import { tenantStoreDatabaseName } from './d1.js';
11
12
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
@@ -178,6 +179,21 @@ class UnattestedSwitch extends Error {
178
179
  const unfencedMessage = (scopeId) => `the deployment serving scope ${scopeId} predates the switch fence (#2045): it cannot refuse an older ` +
179
180
  `switch call's OFF landing after this ON, so no switch is turned on there. Switching off still works. ` +
180
181
  `Redeploy the vertical, then retry.`;
182
+ /**
183
+ * The attachment write's compensation, after its bytes are in the store (#2311). The object
184
+ * is deleted only when the ScopeDO ANSWERED a refusal: `replyOf` turns every DO-side throw
185
+ * into a `failure` value, and the DO does not let a post-commit drain fault become one, so an
186
+ * answered failure means the row did not commit. A rejected call is a transport failure with
187
+ * the commit unknown. A delete there could leave a committed row without its bytes, so the
188
+ * bytes are kept and the error rethrown. If nothing committed, the object is an orphan that
189
+ * no row points at, which the sweep of #2314 is for.
190
+ */
191
+ async function compensatingRefusal(store, key, call) {
192
+ const reply = await call();
193
+ if (reply.failure)
194
+ await store.delete(key).catch(() => { });
195
+ return reply;
196
+ }
181
197
  /** A tenant-held module's OFF answered without `deniesTenantGrants` (#1823). */
182
198
  const undeniedMessage = (scopeId, moduleId) => `the deployment serving scope ${scopeId} predates the kill switch's tenant-grant denial (#1823): ` +
183
199
  `module '${moduleId}' holds a tenant-level grant that its OFF would leave authorizing there. ` +
@@ -187,8 +203,73 @@ const undeniedMessage = (scopeId, moduleId) => `the deployment serving scope ${s
187
203
  * platform's per-tenant binding, read off this script's own env, named by the one shared
188
204
  * encoding (`blobStoreBindingName`) for the tenant the caller passed in.
189
205
  */
206
+ function ambientRecord() {
207
+ return ambientEnv;
208
+ }
209
+ function ambientBinding(name) {
210
+ return ambientRecord()[name] ?? null;
211
+ }
190
212
  function ambientAttachmentBucket(tenantId) {
191
- return ambientEnv[blobStoreBindingName(ATTACHMENT_BLOB_BINDING, tenantId)] ?? null;
213
+ return ambientBinding(blobStoreBindingName(ATTACHMENT_BLOB_BINDING, tenantId));
214
+ }
215
+ /**
216
+ * One store call over an object's fetch, for the vertical's door and the platform's reserved
217
+ * calls alike. A body that is not JSON is the store's failure, answered as a bad envelope.
218
+ */
219
+ function storeObjectSend(object) {
220
+ return async (request) => {
221
+ const response = await object.fetch('https://scope-store/', {
222
+ method: 'POST',
223
+ headers: { 'content-type': 'application/json' },
224
+ body: JSON.stringify(request),
225
+ });
226
+ try {
227
+ return await response.json();
228
+ }
229
+ catch {
230
+ throw new StoreError({ code: 'bad_envelope', status: 502 });
231
+ }
232
+ };
233
+ }
234
+ function isScopeStoreNamespace(value) {
235
+ return (typeof value === 'object' &&
236
+ value !== null &&
237
+ typeof Reflect.get(value, 'idFromName') === 'function' &&
238
+ typeof Reflect.get(value, 'get') === 'function');
239
+ }
240
+ const SCOPE_STORE_BINDING_PREFIX = `${PLATFORM_BINDING_PREFIX}STORE_`;
241
+ /**
242
+ * `platform` unless this host was handed another way to reach a store (#2288): a
243
+ * non-empty `runners` list (in-process), or `transport: 'binding'`, which is the only
244
+ * thing that reads an ambient `SUBSTRAT_STORE_*` namespace. A control plane does not
245
+ * change the answer: whoever wants the direct namespace says so. An explicit
246
+ * `'platform'` beside runners is refused, since the runners would never be used.
247
+ */
248
+ function scopeStoreTransportOf(options) {
249
+ const runners = options.scopeStores?.runners?.length ?? 0;
250
+ const transport = options.scopeStores?.transport;
251
+ if (transport === 'platform' && runners > 0) {
252
+ throw substratError('validation_failed', "scopeStores: transport 'platform' takes no runners; they would never be used");
253
+ }
254
+ if (transport)
255
+ return transport;
256
+ return runners > 0 ? 'binding' : 'platform';
257
+ }
258
+ /** Bindings this script declares by having a `SUBSTRAT_STORE_*` namespace. */
259
+ function ambientScopeStoreBindings() {
260
+ const names = [];
261
+ const env = ambientRecord();
262
+ for (const key of Object.keys(env)) {
263
+ if (!key.startsWith(SCOPE_STORE_BINDING_PREFIX))
264
+ continue;
265
+ const binding = key.slice(SCOPE_STORE_BINDING_PREFIX.length);
266
+ if (!/^[A-Z][A-Z0-9_]*$/.test(binding))
267
+ continue;
268
+ if (!isScopeStoreNamespace(Reflect.get(env, key)))
269
+ continue;
270
+ names.push(binding);
271
+ }
272
+ return names;
192
273
  }
193
274
  /**
194
275
  * #2029: scope instances whose door has read the rewind hold and found NO claim on the scope at
@@ -236,6 +317,11 @@ const holdKeyOf = (kind, key) => (kind === 'system' ? key : peerSubjectRef(key))
236
317
  export class CloudflareScopeHost {
237
318
  admin;
238
319
  scopeNs;
320
+ scopeStoreRunners;
321
+ scopeStoreVertical;
322
+ scopeStoreLifecycle;
323
+ scopeStoreKinds;
324
+ scopeStoreTransport;
239
325
  cp;
240
326
  servicePrincipals;
241
327
  /** No control plane bound — `this.cp` is the throwing null object (Phase 3, CP-less). */
@@ -350,6 +436,11 @@ export class CloudflareScopeHost {
350
436
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
351
437
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
352
438
  this.scopeNs = options.scope;
439
+ this.scopeStoreVertical = options.scopeStores?.vertical;
440
+ this.scopeStoreKinds = options.scopeStores?.kinds ?? {};
441
+ this.scopeStoreTransport = scopeStoreTransportOf(options);
442
+ this.scopeStoreRunners = scopeStoreRunnersOf(options.scopeStores);
443
+ this.scopeStoreLifecycle = scopeStoreLifecycleOf(this.scopeStoreRunners);
353
444
  this.cpLess = !options.controlPlane;
354
445
  this.cp = options.controlPlane
355
446
  ? options.controlPlane.get(options.controlPlane.idFromName('control-plane'))
@@ -1124,7 +1215,9 @@ export class CloudflareScopeHost {
1124
1215
  * control plane, which calls the vertical's `/internal/delete-scope` before deleting
1125
1216
  * the row; this end just destroys its own bytes.
1126
1217
  */
1127
- async deleteScopeLocal(scopeId) {
1218
+ async deleteScopeLocal(scopeId, tenantId) {
1219
+ if (tenantId)
1220
+ await this.wipeScopeStores(tenantId, scopeId);
1128
1221
  await this.scopeStub(scopeId).destroyStorage();
1129
1222
  // #1819: a deleted scope runs nothing; its claims go with it. Best effort, never a failed reap.
1130
1223
  await this.switchHoldsStub()
@@ -1188,6 +1281,40 @@ export class CloudflareScopeHost {
1188
1281
  async databaseSizeLocal(scopeId) {
1189
1282
  return this.scopeStub(scopeId).databaseSize();
1190
1283
  }
1284
+ /**
1285
+ * Each scope store's size in this deployment (#2207). The control plane already
1286
+ * audited the ask; this end does not write the access log again.
1287
+ */
1288
+ async scopeStoreSizesLocal(tenantId, scopeId) {
1289
+ this.refuseLocalStoresOnPlatform('size');
1290
+ return this.readScopeStoreSizes(tenantId, scopeId);
1291
+ }
1292
+ /**
1293
+ * Erase one subject from each scope store in this deployment (#2068). The control
1294
+ * plane calls this once on the serving script, then destroys the key itself.
1295
+ */
1296
+ async scopeStoreExportLocal(tenantId, scopeId) {
1297
+ this.refuseLocalStoresOnPlatform('export');
1298
+ return this.exportScopeStores(tenantId, scopeId);
1299
+ }
1300
+ async scopeStoreImportLocal(tenantId, scopeId, stores) {
1301
+ this.refuseUnusableScopeStores(stores);
1302
+ await this.importScopeStores(tenantId, scopeId, stores);
1303
+ }
1304
+ async scopeStoreEraseLocal(tenantId, scopeId, subjectId) {
1305
+ this.refuseLocalStoresOnPlatform('erase');
1306
+ return this.eraseScopeStores(tenantId, scopeId, subjectId);
1307
+ }
1308
+ /**
1309
+ * On the platform hop this host holds no store object, so it cannot say what its stores
1310
+ * hold: an empty answer would read as "no stores" (#2242). It refuses instead, and the
1311
+ * control plane, which reaches the store script, is the one to ask.
1312
+ */
1313
+ refuseLocalStoresOnPlatform(what) {
1314
+ if (this.scopeStoreTransport === 'platform') {
1315
+ throw substratError('unavailable', `this host reaches its scope stores through the platform and cannot ${what} them here; ask the control plane`);
1316
+ }
1317
+ }
1191
1318
  /**
1192
1319
  * The oldest not-yet-drained events of this host's own scope (#1334) — the far end of
1193
1320
  * the control plane's `EventDrainDelegation`. Bounded the same way the audited verb is,
@@ -1519,6 +1646,42 @@ export class CloudflareScopeHost {
1519
1646
  const store = await this.resolveAttachmentStore(tenantId);
1520
1647
  return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
1521
1648
  }
1649
+ async inspectUploadLink(tenantId, scopeId, secret) {
1650
+ await this.assertLive(tenantId, scopeId);
1651
+ await this.migrateAndRecord(scopeId);
1652
+ return unwrapReply(await this.scopeStub(scopeId).uploadLinkInspectReply(secret, tenantId, scopeId)).view;
1653
+ }
1654
+ async redeemUploadLink(tenantId, scopeId, secret, input) {
1655
+ await this.assertLive(tenantId, scopeId);
1656
+ await this.migrateAndRecord(scopeId);
1657
+ const stub = this.scopeStub(scopeId);
1658
+ const { view, mintedBy } = unwrapReply(await stub.uploadLinkInspectReply(secret, tenantId, scopeId));
1659
+ if (view.entity.entityType !== input.entity.entityType || view.entity.entityId !== input.entity.entityId) {
1660
+ throw substratError('not_found', 'This upload link is not valid for that target.', {
1661
+ reason: UPLOAD_LINK_WRONG_TARGET,
1662
+ });
1663
+ }
1664
+ const store = await this.resolveAttachmentStore(tenantId);
1665
+ const id = ulid();
1666
+ const key = attachmentBlobKey(scopeId, id);
1667
+ const filename = view.filename ?? input.filename;
1668
+ const record = attachmentRecord.parse({
1669
+ id,
1670
+ entity: view.entity,
1671
+ filename,
1672
+ contentType: input.contentType,
1673
+ size: input.body.byteLength,
1674
+ sha256: await attachmentSha256(input.body),
1675
+ visibility: view.visibility,
1676
+ createdBy: mintedBy,
1677
+ createdAt: new Date().toISOString(),
1678
+ });
1679
+ // Bytes first, row second, exactly as an upload: a refusal (size, type, a lost key, a link
1680
+ // someone else spent first) compensates the object away, and the link is unspent with it.
1681
+ // A lost reply keeps the bytes (`compensatingRefusal`); retrying the link is #2315.
1682
+ await store.put(key, input.body, { contentType: input.contentType });
1683
+ return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentRedeemUploadLinkReply(record, secret, tenantId, scopeId)));
1684
+ }
1522
1685
  async getSystemAttachments(moduleId, tenantId, scopeId) {
1523
1686
  // Reuse the system invoke door's module, tenant and lifecycle gate, and its rewind gate
1524
1687
  // (#1834). The DO authorizes each open against the attachment target's read permission as
@@ -1657,16 +1820,10 @@ export class CloudflareScopeHost {
1657
1820
  createdBy,
1658
1821
  createdAt: new Date().toISOString(),
1659
1822
  });
1660
- // Bytes first, row second — a crash between the two leaves an orphaned object
1661
- // (harmless, GC-able), never a row without bytes; a refused gate compensates.
1823
+ // Bytes first, row second — a crash between the two leaves an orphaned object (#2314);
1824
+ // a refused gate compensates, and only an answered refusal does (`compensatingRefusal`).
1662
1825
  await store.put(key, input.body, { contentType: input.contentType });
1663
- try {
1664
- return unwrapReply(await stub.attachmentAddReply(record, asPrincipalId, tenantId, scopeId, connectionId));
1665
- }
1666
- catch (err) {
1667
- await store.delete(key).catch(() => { });
1668
- throw err;
1669
- }
1826
+ return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentAddReply(record, asPrincipalId, tenantId, scopeId, connectionId)));
1670
1827
  },
1671
1828
  list: async (entity) => unwrapReply(await stub.attachmentListReply(entity, asPrincipalId, tenantId, scopeId, connectionId)),
1672
1829
  open: async (attachmentId) => {
@@ -1706,6 +1863,7 @@ export class CloudflareScopeHost {
1706
1863
  return { record, body: obj.body, contentType: obj.contentType ?? record.contentType };
1707
1864
  }
1708
1865
  async importScope(actor, input, dump) {
1866
+ this.refuseUnusableScopeStores(dump.stores, input.vertical ?? null);
1709
1867
  // Create the destination scope (directory row + DO + lazy migrate); the DO then
1710
1868
  // replaces its provisioned schema with the dump wholesale (drop-then-replay), so
1711
1869
  // the end state is the dump, at the source's frontier. Provenance is stamped from
@@ -1720,6 +1878,7 @@ export class CloudflareScopeHost {
1720
1878
  // A fork: its callers (snapshotScope) hand it a dump the platform exported.
1721
1879
  exact: true,
1722
1880
  });
1881
+ await this.importScopeStores(input.tenantId, input.scopeId, dump.stores);
1723
1882
  await this.admin.activateScope(actor, input.tenantId, input.scopeId);
1724
1883
  await this.recordAdmin(actor, 'importScope', { tenantId: input.tenantId, scopeId: input.scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1725
1884
  }
@@ -1728,6 +1887,7 @@ export class CloudflareScopeHost {
1728
1887
  const existing = await this.admin.getScopeRecord(actor, tenantId, scopeId);
1729
1888
  if (!existing)
1730
1889
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1890
+ this.refuseUnusableScopeStores(dump.stores, existing.vertical);
1731
1891
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
1732
1892
  // the directory's recorded-off modules go back off in the replay's own event. Only where
1733
1893
  // this is the scope's real store, as for the provision's seat above; a hosted scope is
@@ -1739,10 +1899,18 @@ export class CloudflareScopeHost {
1739
1899
  switchOff: carry ? { at: replayAt, ...carry } : undefined,
1740
1900
  sourceScopeId: opts?.sourceScopeId ?? dump.scopeId,
1741
1901
  });
1742
- await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, { sourceScopeId: dump.scopeId, tables: dump.tables.length, capturedAt: dump.capturedAt });
1902
+ await this.importScopeStores(tenantId, scopeId, dump.stores);
1903
+ const replay = await this.replayShreddedStoreErasures(actor, tenantId, scopeId, (opts?.sourceScopeId ?? dump.scopeId));
1904
+ await this.recordAdmin(actor, 'restoreScope', { tenantId, scopeId }, null, {
1905
+ sourceScopeId: dump.scopeId,
1906
+ tables: dump.tables.length,
1907
+ capturedAt: dump.capturedAt,
1908
+ ...replay,
1909
+ });
1743
1910
  if (carry) {
1744
1911
  await this.admin.reassertSystemSwitches(actor, { tenantId, scopeId }, { appliedInUnit: switchedOff });
1745
1912
  }
1913
+ return replay;
1746
1914
  }
1747
1915
  /**
1748
1916
  * #1742, #2029: what this host's own unit carries of the directory's record — the modules and
@@ -1814,6 +1982,7 @@ export class CloudflareScopeHost {
1814
1982
  // Storage BEFORE the directory row: a crash between the two leaves a visible row
1815
1983
  // over empty storage — re-running deleteSnapshot converges — never orphaned bytes
1816
1984
  // with no record (the §9 hazard). Hostname rows go with the directory delete.
1985
+ await this.wipeScopeStores(tenantId, scopeId);
1817
1986
  await this.scopeStub(scopeId).destroyStorage();
1818
1987
  await this.cp.deleteScopeDirectory(scopeId);
1819
1988
  await this.recordAdmin(actor, 'deleteSnapshot', { tenantId, scopeId }, null, {
@@ -1866,6 +2035,306 @@ export class CloudflareScopeHost {
1866
2035
  await this.migrateAndRecord(scopeId);
1867
2036
  return this.buildStub(tenantId, scopeId, principal, undefined, undefined, options);
1868
2037
  }
2038
+ async getScopeStore(_principal, tenantId, scopeId, binding, options) {
2039
+ if (this.scopeStoreTransport === 'platform') {
2040
+ return {
2041
+ tenantId,
2042
+ scopeId,
2043
+ subjectKind: 'principal',
2044
+ invoke: (operation, input, ...extra) => observeStoreCall(binding, operation, () => callScopeStore(async (request) => {
2045
+ const response = await this.fetchImpl(STORE_CALL_URL, {
2046
+ method: 'POST',
2047
+ headers: { 'content-type': 'application/json' },
2048
+ body: JSON.stringify({ scopeId, binding, operation: request.operation, input: request.input }),
2049
+ });
2050
+ try {
2051
+ return await response.json();
2052
+ }
2053
+ catch {
2054
+ throw new StoreError({ code: 'bad_envelope', status: 502 });
2055
+ }
2056
+ }, operation, input, ...extra), options?.onStoreCall),
2057
+ };
2058
+ }
2059
+ // A missing binding is answered before any directory read. A CP-less host's
2060
+ // directory throws a plain Error, and a vertical with no store must not meet it.
2061
+ const runner = this.scopeStoreRunners.get(binding);
2062
+ const bound = runner ? undefined : ambientBinding(scopeStoreBindingName(binding));
2063
+ if (!runner && (this.scopeStoreRunners.size > 0 || !isScopeStoreNamespace(bound))) {
2064
+ throw substratError('not_found', `no scope store is bound for '${binding}'`);
2065
+ }
2066
+ const vertical = await this.scopeStoreVerticalOf(tenantId, scopeId);
2067
+ await this.assertLive(tenantId, scopeId);
2068
+ if (runner) {
2069
+ await this.scopeStoreLifecycle.start();
2070
+ return {
2071
+ tenantId,
2072
+ scopeId,
2073
+ subjectKind: 'principal',
2074
+ invoke: (operation, input, ...extra) => observeStoreCall(binding, operation, () => callScopeStore((request) => runner.invoke({ tenantId, vertical, scopeId, binding }, request), operation, input, ...extra), options?.onStoreCall),
2075
+ };
2076
+ }
2077
+ const namespace = bound;
2078
+ const objectName = scopeStoreObjectName(tenantId, vertical, scopeId, binding);
2079
+ const object = namespace.get(namespace.idFromName(objectName));
2080
+ return {
2081
+ tenantId,
2082
+ scopeId,
2083
+ subjectKind: 'principal',
2084
+ invoke: (operation, input, ...extra) => observeStoreCall(binding, operation, () => callScopeStore(storeObjectSend(object), operation, input, ...extra), options?.onStoreCall),
2085
+ };
2086
+ }
2087
+ /**
2088
+ * Which vertical names the store object. With a control plane, the scope row.
2089
+ * Without one, {@link CloudflareScopeHostOptions.scopeStores} — the worker
2090
+ * already knows which vertical it is, and the directory is not there to ask.
2091
+ */
2092
+ async scopeStoreVerticalOf(tenantId, scopeId) {
2093
+ if (this.cpLess) {
2094
+ if (!this.scopeStoreVertical) {
2095
+ throw substratError('unavailable', 'a scope store on a host with no control plane needs scopeStores.vertical');
2096
+ }
2097
+ return this.scopeStoreVertical;
2098
+ }
2099
+ const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2100
+ const vertical = scope?.vertical;
2101
+ if (!vertical) {
2102
+ throw substratError('not_found', `scope ${scopeId} has no vertical, so it has no scope store`);
2103
+ }
2104
+ if (this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical) {
2105
+ throw substratError('forbidden', `this host serves vertical '${this.scopeStoreVertical}', and scope ${scopeId} runs '${vertical}'`);
2106
+ }
2107
+ return vertical;
2108
+ }
2109
+ /**
2110
+ * Configured runners, or the ambient namespaces when this host was told to use a
2111
+ * direct binding. The platform hop holds no object in this isolate, so an ambient
2112
+ * namespace is not one of its stores, and the admin's store paths answer for none: the
2113
+ * control plane reaches the platform's stores itself (see `eraseScopeStores`).
2114
+ */
2115
+ scopeStoreBindingNames() {
2116
+ if (this.scopeStoreTransport === 'platform')
2117
+ return [];
2118
+ if (this.scopeStoreRunners.size > 0)
2119
+ return [...this.scopeStoreRunners.keys()];
2120
+ return ambientScopeStoreBindings();
2121
+ }
2122
+ /**
2123
+ * One reserved call. A configured runner is preferred; otherwise the ambient
2124
+ * namespace. `callReservedStoreOperation` refuses a name that is not reserved.
2125
+ */
2126
+ async invokeReservedStore(address, operation, input) {
2127
+ const runner = this.scopeStoreRunners.get(address.binding);
2128
+ if (runner) {
2129
+ await this.scopeStoreLifecycle.start();
2130
+ return callReservedStoreOperation((request) => runner.invoke(address, request), operation, input);
2131
+ }
2132
+ if (this.scopeStoreRunners.size > 0) {
2133
+ throw substratError('not_found', `no scope store is bound for '${address.binding}'`);
2134
+ }
2135
+ const bound = ambientBinding(scopeStoreBindingName(address.binding));
2136
+ if (!isScopeStoreNamespace(bound)) {
2137
+ throw substratError('not_found', `no scope store is bound for '${address.binding}'`);
2138
+ }
2139
+ const objectName = scopeStoreObjectName(address.tenantId, address.vertical, address.scopeId, address.binding);
2140
+ const object = bound.get(bound.idFromName(objectName));
2141
+ return callReservedStoreOperation(storeObjectSend(object), operation, input);
2142
+ }
2143
+ /**
2144
+ * Each declared binding's `substrat.size`. No bindings means an empty list,
2145
+ * and that answer does not read the directory: a host with no store, including
2146
+ * one with no control plane, has nothing to size. Once a binding exists, the
2147
+ * scope is gated before any object is addressed: by the directory with a control
2148
+ * plane, and without one by the door's own gate (`assertLive`: the scope's
2149
+ * admission of the tenant and its delivered lifecycle), as `getScopeStore` is.
2150
+ */
2151
+ async readScopeStoreSizes(tenantId, scopeId) {
2152
+ const bindings = this.scopeStoreBindingNames();
2153
+ if (bindings.length === 0)
2154
+ return [];
2155
+ if (!this.cpLess)
2156
+ await this.scopeRecordForRead(tenantId, scopeId);
2157
+ const vertical = await this.scopeStoreVerticalOf(tenantId, scopeId);
2158
+ if (this.cpLess)
2159
+ await this.assertLive(tenantId, scopeId);
2160
+ const sizes = [];
2161
+ for (const binding of bindings) {
2162
+ const { bytes } = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.size');
2163
+ sizes.push({ binding, bytes });
2164
+ }
2165
+ return sizes;
2166
+ }
2167
+ /**
2168
+ * `substrat.erase` on each declared binding (#2068). A store error throws so the
2169
+ * caller can leave the subject key untouched. An empty answer means this host holds no
2170
+ * store object for the scope, never that it could not tell. On the platform hop that is
2171
+ * true by construction (`scopeStoreBindingNames` is empty): the stores live behind the
2172
+ * store script, and control-plane-api erases, exports, sizes and wipes them itself around
2173
+ * its calls to this host's admin (`shredSubject`, `exportScope`, `scopeStoreSizes`, the
2174
+ * wipe and the replay); an erasure reaches them before the host's shred destroys the key. Only the vertical-host `*Local` reads, which no control plane fronts,
2175
+ * refuse on the hop instead (`refuseLocalStoresOnPlatform`).
2176
+ */
2177
+ async eraseScopeStores(tenantId, scopeId, subjectId) {
2178
+ const bindings = this.scopeStoreBindingNames();
2179
+ if (bindings.length === 0)
2180
+ return [];
2181
+ const vertical = await this.erasureStoreVertical(tenantId, scopeId);
2182
+ if (!vertical)
2183
+ return [];
2184
+ const erasures = [];
2185
+ for (const binding of bindings) {
2186
+ const { erased } = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.erase', { subjectId });
2187
+ erasures.push({ binding, erased });
2188
+ }
2189
+ return erasures;
2190
+ }
2191
+ /** A runner's kind, or the kind declared for an ambient binding. Never invented. */
2192
+ scopeStoreKind(binding) {
2193
+ const kind = this.scopeStoreRunners.get(binding)?.kind ?? this.scopeStoreKinds[binding];
2194
+ if (!kind) {
2195
+ throw substratError('unavailable', `scope store '${binding}' has no declared kind`);
2196
+ }
2197
+ return kind;
2198
+ }
2199
+ /**
2200
+ * A dump's stores this host cannot load are refused before any write (#2211, L8): a
2201
+ * binding that is not bound, a kind other than the bound one, or any store at all for
2202
+ * a destination with no vertical (no store object can be named for it, so loading would
2203
+ * drop them), or a destination on a vertical other than the one this host is pinned to.
2204
+ * `vertical` undefined is a caller that cannot read the destination here;
2205
+ * `importScopeStores` then refuses a scope with no vertical itself, still before a store
2206
+ * write. A store with no `kind` is refused: the dump contract requires one, and an
2207
+ * unparsed dump would otherwise skip the kind comparison.
2208
+ */
2209
+ refuseUnusableScopeStores(stores, vertical) {
2210
+ const listed = stores ?? [];
2211
+ if (listed.length === 0)
2212
+ return;
2213
+ if (vertical === null) {
2214
+ throw substratError('conflict', 'dump carries scope stores, and the destination scope has no vertical to hold them');
2215
+ }
2216
+ if (vertical !== undefined && this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical) {
2217
+ throw substratError('forbidden', `this host serves vertical '${this.scopeStoreVertical}', and the destination scope runs '${vertical}'`);
2218
+ }
2219
+ const bound = new Set(this.scopeStoreBindingNames());
2220
+ for (const store of listed) {
2221
+ if (!bound.has(store.binding)) {
2222
+ throw substratError('not_found', `dump names scope store '${store.binding}', which is not bound`);
2223
+ }
2224
+ if (!store.kind) {
2225
+ throw substratError('validation_failed', `dump's scope store '${store.binding}' names no kind`);
2226
+ }
2227
+ const kind = this.scopeStoreRunners.get(store.binding)?.kind ?? this.scopeStoreKinds[store.binding];
2228
+ if (store.kind !== kind) {
2229
+ throw substratError('conflict', `dump's scope store '${store.binding}' is kind '${store.kind}', and this host binds kind '${kind ?? 'undeclared'}'`);
2230
+ }
2231
+ }
2232
+ }
2233
+ async exportScopeStores(tenantId, scopeId) {
2234
+ // No binding is no store, answered before the directory read: a host with no control
2235
+ // plane cannot make that read, and a deployment with no store must not fail an export.
2236
+ const bindings = this.scopeStoreBindingNames();
2237
+ if (bindings.length === 0)
2238
+ return [];
2239
+ const vertical = await this.lifecycleStoreVertical(tenantId, scopeId);
2240
+ if (!vertical)
2241
+ return [];
2242
+ const stores = [];
2243
+ for (const binding of bindings) {
2244
+ const exported = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.export');
2245
+ stores.push({
2246
+ binding,
2247
+ kind: this.scopeStoreKind(binding),
2248
+ tables: exported.tables,
2249
+ });
2250
+ }
2251
+ return stores;
2252
+ }
2253
+ async importScopeStores(tenantId, scopeId, stores) {
2254
+ const listed = stores ?? [];
2255
+ if (listed.length === 0)
2256
+ return;
2257
+ const vertical = await this.lifecycleStoreVertical(tenantId, scopeId);
2258
+ if (!vertical) {
2259
+ throw substratError('conflict', `dump carries scope stores, and scope ${scopeId} has no vertical to hold them`);
2260
+ }
2261
+ for (const store of listed) {
2262
+ await this.invokeReservedStore({ tenantId, vertical, scopeId, binding: store.binding }, 'substrat.import', { tables: store.tables });
2263
+ }
2264
+ }
2265
+ /**
2266
+ * `substrat.erase` again for every tombstoned subject (#2207), on the target's and the
2267
+ * source's marks (`replayStoreErasures`). A target this host names no store for (no
2268
+ * binding, or no vertical a store is kept for) has nothing to replay into: nothing is read.
2269
+ */
2270
+ async replayShreddedStoreErasures(actor, tenantId, target, source) {
2271
+ if (this.scopeStoreBindingNames().length === 0)
2272
+ return undefined;
2273
+ if (!(await this.erasureStoreVertical(tenantId, target)))
2274
+ return undefined;
2275
+ return replayStoreErasures({
2276
+ admin: this.admin,
2277
+ actor,
2278
+ tenantId,
2279
+ target,
2280
+ source,
2281
+ erase: async (subjectId) => (await this.eraseScopeStores(tenantId, target, subjectId)).length,
2282
+ });
2283
+ }
2284
+ /** `substrat.wipe` on each declared binding, before the scope's own storage goes. */
2285
+ async wipeScopeStores(tenantId, scopeId) {
2286
+ const bindings = this.scopeStoreBindingNames();
2287
+ if (bindings.length === 0)
2288
+ return;
2289
+ const vertical = await this.lifecycleStoreVertical(tenantId, scopeId);
2290
+ if (!vertical)
2291
+ return;
2292
+ for (const binding of bindings) {
2293
+ await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.wipe');
2294
+ }
2295
+ }
2296
+ /**
2297
+ * The vertical a store export, import or wipe names. With a control plane, the scope row
2298
+ * (`scopeRecordForRead`: unknown and reaped scopes refused, `null` for a scope with no
2299
+ * vertical, a vertical this host is not pinned to refused). Without one, there is no
2300
+ * directory to read: the host's configured vertical (`scopeStoreVerticalOf`).
2301
+ *
2302
+ * Never gated on the scope being active, on either host. These calls are the platform's own
2303
+ * lifecycle work on scopes that often are not: a reap backs up and wipes an archived scope, a
2304
+ * snapshot or a full pull may copy a suspended one, and a fork's stores land before it is
2305
+ * activated. The scope's tables are exported and imported on the same terms.
2306
+ */
2307
+ async lifecycleStoreVertical(tenantId, scopeId) {
2308
+ if (this.cpLess)
2309
+ return this.scopeStoreVerticalOf(tenantId, scopeId);
2310
+ const scope = await this.scopeRecordForRead(tenantId, scopeId);
2311
+ if (!scope.vertical)
2312
+ return null;
2313
+ if (this.scopeStoreVertical !== undefined && scope.vertical !== this.scopeStoreVertical) {
2314
+ throw substratError('forbidden', `this host serves vertical '${this.scopeStoreVertical}', and scope ${scopeId} runs '${scope.vertical}'`);
2315
+ }
2316
+ return scope.vertical;
2317
+ }
2318
+ /**
2319
+ * The vertical an erasure names a store for. With a control plane, the scope row
2320
+ * decides: no vertical, or not the vertical this host is pinned to, means no
2321
+ * store object of this host's is the scope's — an empty answer, which shred
2322
+ * needs for a scope that never had a vertical. With no control plane and no
2323
+ * `scopeStores.vertical`, the host cannot tell which objects are the scope's, so
2324
+ * it refuses `unavailable` as the size read and the door do, and the subject key
2325
+ * survives until the stores can be erased.
2326
+ */
2327
+ async erasureStoreVertical(tenantId, scopeId) {
2328
+ if (this.cpLess)
2329
+ return this.scopeStoreVerticalOf(tenantId, scopeId);
2330
+ const scope = await this.cp.getScopeRecord(tenantId, scopeId);
2331
+ const vertical = scope?.vertical ?? undefined;
2332
+ if (!vertical)
2333
+ return undefined;
2334
+ if (this.scopeStoreVertical !== undefined && vertical !== this.scopeStoreVertical)
2335
+ return undefined;
2336
+ return vertical;
2337
+ }
1869
2338
  /**
1870
2339
  * A scope stub whose authority is a CONNECTION (#97).
1871
2340
  *
@@ -2795,7 +3264,7 @@ export class CloudflareScopeHost {
2795
3264
  };
2796
3265
  }
2797
3266
  async close() {
2798
- // Nothing to drain: every admin write awaits its RPC to completion inline.
3267
+ await this.scopeStoreLifecycle.stop();
2799
3268
  }
2800
3269
  // -- admin surface --------------------------------------------------------
2801
3270
  /** #977: this host, with every admin row it writes naming who the actor acted for. */
@@ -3823,12 +4292,12 @@ export class CloudflareScopeHost {
3823
4292
  });
3824
4293
  await this.recordAdmin(actor, 'setHostnameIssuance', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status: fields.status, note: fields.note ?? null });
3825
4294
  },
3826
- unbindHostname: async (actor, raw) => {
4295
+ unbindHostname: async (actor, raw, owner) => {
3827
4296
  const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
3828
- const row = await this.cp.readHostname(hostname);
4297
+ // The row the delete removed, so an owner is judged by the delete itself (#2285).
4298
+ const row = await this.cp.deleteHostname(hostname, owner);
3829
4299
  if (!row)
3830
4300
  return; // idempotent, and a no-op is not audited
3831
- await this.cp.deleteHostname(hostname);
3832
4301
  await this.recordAdmin(actor, 'unbindHostname', { tenantId: row.tenant_id, scopeId: row.scope_id }, { hostname, status: row.status }, null);
3833
4302
  },
3834
4303
  listHostnames: async (actor, filter) => {
@@ -3939,7 +4408,7 @@ export class CloudflareScopeHost {
3939
4408
  // The SQL migrations are stored apart from the manifest (#1764), so no read of the
3940
4409
  // version but the promote review's carries them.
3941
4410
  const split = splitManifestMigrations(manifestJson ?? null, 'push');
3942
- await this.cp.insertVersion({
4411
+ await this.directory('insertVersion', {
3943
4412
  ...audited,
3944
4413
  manifestJson: split.manifestJson,
3945
4414
  migrations: split.migrations,
@@ -3954,6 +4423,11 @@ export class CloudflareScopeHost {
3954
4423
  admission: selfAdmits ? 'admitted' : 'pending',
3955
4424
  });
3956
4425
  },
4426
+ verticalScopeStores: async (actor, verticalSlug) => {
4427
+ const stores = await this.directory('verticalScopeStores', verticalSlug);
4428
+ await this.recordAccess(actor, 'verticalScopeStores', {}, { verticalSlug }, stores.length);
4429
+ return stores;
4430
+ },
3957
4431
  listVersions: async (actor, verticalSlug, page) => {
3958
4432
  const rows = await this.cp.listVersions(verticalSlug, page);
3959
4433
  await this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
@@ -4401,6 +4875,11 @@ export class CloudflareScopeHost {
4401
4875
  await this.recordAccess(actor, 'scopeDatabaseSize', { tenantId, scopeId }, null, 1);
4402
4876
  return bytes;
4403
4877
  },
4878
+ scopeStoreSizes: async (actor, tenantId, scopeId) => {
4879
+ const sizes = await this.readScopeStoreSizes(tenantId, scopeId);
4880
+ await this.recordAccess(actor, 'scopeStoreSizes', { tenantId, scopeId }, null, sizes.length);
4881
+ return sizes;
4882
+ },
4404
4883
  rewindScope: async (actor, tenantId, scopeId, bookmark, opts) => {
4405
4884
  const scope = await this.cp.getScopeRecord(tenantId, scopeId);
4406
4885
  if (!scope)
@@ -4571,6 +5050,43 @@ export class CloudflareScopeHost {
4571
5050
  createdAt: r.created_at,
4572
5051
  }));
4573
5052
  },
5053
+ // -- store-kind releases, rollouts and holds (D-63, #2300) -------------------
5054
+ recordStoreKindRelease: async (actor, input) => {
5055
+ const at = new Date().toISOString();
5056
+ const { record, created } = await this.directory('recordStoreKindRelease', { ...input, actor, at });
5057
+ if (created)
5058
+ await this.recordAdmin(actor, 'uploadStoreKindRelease', { tenantId: null }, null, record);
5059
+ return record;
5060
+ },
5061
+ getStoreKindRelease: async (actor, kind, sha256) => {
5062
+ const record = await this.cp.getStoreKindRelease(kind, sha256);
5063
+ await this.recordAccess(actor, 'getStoreKindRelease', { tenantId: null }, { kind, sha256 }, record ? 1 : 0);
5064
+ return record;
5065
+ },
5066
+ storeKindState: async (actor, kind) => {
5067
+ const state = await this.cp.storeKindState(kind);
5068
+ await this.recordAccess(actor, 'storeKindState', { tenantId: null }, { kind }, state.rollouts.length);
5069
+ return state;
5070
+ },
5071
+ storeKindDeclarations: async (actor, kind) => {
5072
+ const declared = await this.directory('storeKindDeclarations', kind);
5073
+ await this.recordAccess(actor, 'storeKindDeclarations', { tenantId: null }, { kind }, declared.verticals.length);
5074
+ return declared;
5075
+ },
5076
+ holdStoreKind: async (_actor, input) => this.directory('holdStoreKind', input),
5077
+ releaseStoreKindHold: async (_actor, input) => {
5078
+ await this.cp.releaseStoreKindHold(input);
5079
+ },
5080
+ beginStoreKindRollout: async (actor, input) => {
5081
+ const row = await this.directory('beginStoreKindRollout', { ...input, actor, at: new Date().toISOString() });
5082
+ await this.recordAdmin(actor, input.action === 'rollback' ? 'rollBackStoreKind' : 'rollOutStoreKind', { tenantId: null }, null, row);
5083
+ return row;
5084
+ },
5085
+ finishStoreKindRollout: async (actor, input) => {
5086
+ const row = await this.directory('finishStoreKindRollout', { ...input, at: new Date().toISOString() });
5087
+ await this.recordAdmin(actor, row.action === 'rollback' ? 'rollBackStoreKind' : 'rollOutStoreKind', { tenantId: null }, null, row);
5088
+ return row;
5089
+ },
4574
5090
  listScopeTables: async (actor, tenantId, scopeId) => {
4575
5091
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
4576
5092
  // that does not resolve is unreachable, never another tenant's database.
@@ -4836,7 +5352,8 @@ export class CloudflareScopeHost {
4836
5352
  await this.scopeRecordForRead(tenantId, scopeId);
4837
5353
  const tables = await this.scopeStub(scopeId).exportDump();
4838
5354
  await this.recordAccess(actor, 'exportScope', { tenantId, scopeId }, null, tables.length);
4839
- return { tenantId, scopeId, capturedAt: new Date().toISOString(), tables };
5355
+ const stores = await this.exportScopeStores(tenantId, scopeId);
5356
+ return { tenantId, scopeId, capturedAt: new Date().toISOString(), tables, stores };
4840
5357
  },
4841
5358
  exportDirectory: async (actor) => {
4842
5359
  // No K-3 cross-check to make: there is one directory, and it is the thing that
@@ -4930,6 +5447,7 @@ export class CloudflareScopeHost {
4930
5447
  throw substratError('conflict', `scope ${scopeId} still resolves hostname '${bound[0].hostname}' — ` +
4931
5448
  `unbind it before reaping (reap wipes storage and cannot be undone)`);
4932
5449
  }
5450
+ await this.wipeScopeStores(tenantId, scopeId);
4933
5451
  await this.scopeStub(scopeId).destroyStorage();
4934
5452
  // The recoverable copy the caller stored first (#493), named in the audit entry so
4935
5453
  // the trail answers "was there a backup" without correlating two timestamps.
@@ -4950,11 +5468,20 @@ export class CloudflareScopeHost {
4950
5468
  await this.recordAccess(actor, 'openSubjectPayloads', { tenantId, scopeId }, { subjects: new Set(items.map((i) => i.subjectId)).size }, opened.filter((o) => o !== null).length);
4951
5469
  return opened;
4952
5470
  },
4953
- shredSubject: async (actor, tenantId, scopeId, subjectId) => {
5471
+ listShreddedSubjects: async (actor, tenantId, scopeId, cursor) => {
4954
5472
  await this.assertScope(tenantId, scopeId);
4955
- // #1722: a direct shred claims nothing; it records the epoch it ran under, as an orchestrated
4956
- // one records the epoch its claim compared, so the backfill can order every erasure.
4957
- const stamp = { erasureEpoch: await this.cp.scopeErasureEpoch(tenantId, scopeId), path: 'direct' };
5473
+ const rows = await this.cp.listShreddedSubjects(scopeId, tenantId, cursor ?? null);
5474
+ const subjects = rows.slice(0, 100);
5475
+ const nextCursor = rows.length > 100 ? (subjects[99] ?? null) : null;
5476
+ await this.recordAccess(actor, 'listShreddedSubjects', { tenantId, scopeId }, null, subjects.length);
5477
+ return { subjects, nextCursor };
5478
+ },
5479
+ shredSubject: async (actor, tenantId, scopeId, subjectId, fence) => {
5480
+ await this.assertScope(tenantId, scopeId);
5481
+ // #1722: a direct shred records the epoch it ran under, as an orchestrated one records the
5482
+ // epoch its claim compared, so the backfill can order every erasure. With a fence (#2285) it
5483
+ // claims what it read just before the key goes, and records the epoch that claim compared.
5484
+ const stamp = { erasureEpoch: fence?.epoch ?? await this.cp.scopeErasureEpoch(tenantId, scopeId), path: 'direct' };
4958
5485
  // Redact the live spine FIRST, destroy the key LAST. Both halves are idempotent and
4959
5486
  // a crash between them converges on retry, so the order is decided by which
4960
5487
  // half-done state harms the person: dying after the redaction leaves ciphertext in
@@ -5014,6 +5541,10 @@ export class CloudflareScopeHost {
5014
5541
  // The directory's failure text (#1632) — a drain failure quoting one of those intents,
5015
5542
  // an issue's exemplar, a sweep record's error. Still before the key.
5016
5543
  await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
5544
+ // Scope stores after the scope-side redaction and before the key (#2068).
5545
+ const storeErasures = await this.eraseScopeStores(tenantId, scopeId, subjectId);
5546
+ if (fence)
5547
+ await this.claimSubjectErasure(tenantId, scopeId, fence);
5017
5548
  const at = new Date().toISOString();
5018
5549
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, at);
5019
5550
  const receipt = subjectShredReceipt.parse({
@@ -5022,6 +5553,7 @@ export class CloudflareScopeHost {
5022
5553
  intentsRedacted,
5023
5554
  jobRunsRedacted,
5024
5555
  ...vertical,
5556
+ storeErasures,
5025
5557
  keyDestroyed: existed,
5026
5558
  tombstoned: true,
5027
5559
  });
@@ -5035,13 +5567,11 @@ export class CloudflareScopeHost {
5035
5567
  return receipt;
5036
5568
  },
5037
5569
  scopeErasureEpoch: async (_actor, tenantId, scopeId) => this.cp.scopeErasureEpoch(tenantId, scopeId),
5038
- finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected) => {
5570
+ finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected, storeErasures = []) => {
5039
5571
  await this.assertScope(tenantId, scopeId);
5040
5572
  if (redactions.length === 0)
5041
5573
  throw substratError('conflict', 'subject erasure has no confirmed scope redaction');
5042
- if (!await this.cp.claimSubjectErasure(tenantId, scopeId, expected.versionId, expected.servingRef, expected.epoch, expected.copyCount)) {
5043
- throw substratError('precondition_failed', 'scope route or copy inventory changed during subject erasure; retry after it settles');
5044
- }
5574
+ await this.claimSubjectErasure(tenantId, scopeId, expected);
5045
5575
  const intentIds = [...new Set(redactions.flatMap((r) => r.intentIds))];
5046
5576
  await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
5047
5577
  const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, new Date().toISOString());
@@ -5053,6 +5583,7 @@ export class CloudflareScopeHost {
5053
5583
  verticalRows: redactions.flatMap((r) => r.vertical.verticalRows),
5054
5584
  hookRows: redactions.flatMap((r) => r.vertical.hookRows),
5055
5585
  unreachedEntities: redactions.flatMap((r) => r.vertical.unreachedEntities),
5586
+ storeErasures,
5056
5587
  keyDestroyed: existed,
5057
5588
  tombstoned: true,
5058
5589
  });
@@ -5166,6 +5697,7 @@ export class CloudflareScopeHost {
5166
5697
  })),
5167
5698
  storage: rows.storage,
5168
5699
  storageAttempts: rows.storageAttempts,
5700
+ storeStorage: rows.storeStorage,
5169
5701
  });
5170
5702
  // Tenants covered, not totals: "read one tenant's meter" and "metered the whole
5171
5703
  // fleet" are different acts, and K-24 exists to tell them apart.
@@ -5475,6 +6007,10 @@ export class CloudflareScopeHost {
5475
6007
  const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
5476
6008
  await this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
5477
6009
  },
6010
+ recordConnectionBindingChange: async (actor, entry) => {
6011
+ const { tenantId, scopeId, ...after } = connectionBindingAudit.parse(entry);
6012
+ await this.recordAdmin(actor, 'bindConnectionScope', { tenantId, scopeId }, null, after);
6013
+ },
5478
6014
  recordPeerBindingChange: async (actor, entry) => {
5479
6015
  const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
5480
6016
  await this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
@@ -5979,6 +6515,12 @@ export class CloudflareScopeHost {
5979
6515
  * subject-key operation touches anything. Without it a caller could reach another
5980
6516
  * tenant's keys by naming their scope id.
5981
6517
  */
6518
+ /** The erasure claim (`SUBJECT_ERASURE_CLAIM_SQL`); a scope that moved refuses with the key kept. */
6519
+ async claimSubjectErasure(tenantId, scopeId, fence) {
6520
+ if (!await this.cp.claimSubjectErasure(tenantId, scopeId, fence)) {
6521
+ throw substratError('precondition_failed', 'scope route or copy inventory changed during subject erasure; retry after it settles');
6522
+ }
6523
+ }
5982
6524
  async assertScope(tenantId, scopeId) {
5983
6525
  const rec = await this.cp.getScopeRecord(tenantId, scopeId);
5984
6526
  if (!rec)
@@ -6063,6 +6605,11 @@ export class CloudflareScopeHost {
6063
6605
  */
6064
6606
  async connectionGrantsInScope(tenantId, scopeId) {
6065
6607
  await this.validateScopeAccess(tenantId, scopeId);
6608
+ if (this.connectorDelegation) {
6609
+ if (!this.connectorDelegation.grants)
6610
+ throw new Error('connector grant inspection is not configured for the serving deployment');
6611
+ return this.connectorDelegation.grants({ tenantId, scopeId });
6612
+ }
6066
6613
  await this.migrateAndRecord(scopeId);
6067
6614
  const now = new Date().toISOString();
6068
6615
  // Both stores again, but the CF split is not the pure adapter's. The DO holds the
@@ -6762,39 +7309,7 @@ export class CloudflareScopeHost {
6762
7309
  expires_at: null,
6763
7310
  lockout_reseat: true,
6764
7311
  },
6765
- // #461: each registered module's SCHEDULE grants (#383) ride the same unit —
6766
- // the CP-less mirror of `provisionScope`'s seatTuple loop. Without them the
6767
- // grant-is-the-switch check makes every schedule a silent no-op (`fired: 0`,
6768
- // no error — the #49 unfalsifiable zero).
6769
- ...[...this.moduleSchedules].flatMap(([modId, schedules]) => {
6770
- const perms = new Set();
6771
- for (const s of schedules)
6772
- for (const p of s.permissions)
6773
- perms.add(p);
6774
- return [...perms].map((perm) => ({
6775
- subject: `system:${modId}`,
6776
- relation: `granted:${perm}`,
6777
- object: `scope:${input.scopeId}`,
6778
- expires_at: null,
6779
- }));
6780
- }),
6781
- // #1706: each declared PEER's grants ride the same unit — the CP-less mirror of
6782
- // `provisionScope`'s peer seat, through the one function that decides it.
6783
- ...peerSeats(collectPeers(this.peerSources), input.scopeId).map((seat) => ({
6784
- subject: seat.subject,
6785
- relation: seat.relation,
6786
- object: seat.object,
6787
- expires_at: null,
6788
- })),
6789
- // #592: the tenant's connection grants ride the same unit — the provision-time
6790
- // mirror of `connectorGrantLocal`, so the connector return path works on every
6791
- // install, not only the one that existed when `grantToConnection` ran.
6792
- ...(input.connectionGrants ?? []).map((g) => ({
6793
- subject: `connection:${g.connectionId}`,
6794
- relation: `granted:${g.permission}`,
6795
- object: `scope:${input.scopeId}`,
6796
- expires_at: g.expiresAt ?? null,
6797
- })),
7312
+ ...this.declaredSeatsLocal(input.scopeId, input.connectionGrants),
6798
7313
  ],
6799
7314
  // #406: identity links, delivered by the platform with provisioning exactly as
6800
7315
  // entitlements are (#310). Preserve-on-undefined, so an absent field never wipes
@@ -6831,6 +7346,53 @@ export class CloudflareScopeHost {
6831
7346
  ...(shapes?.boundEver ? { boundShapeTypes: shapes.boundEver } : {}),
6832
7347
  };
6833
7348
  }
7349
+ /**
7350
+ * The tuples this deployment's code declares for one scope, seated by every provision and
7351
+ * reconcile: each module's SCHEDULE grants (#383, #461 — without them the grant-is-the-switch
7352
+ * check makes every schedule a silent no-op), each declared PEER's keys (#1706, through the one
7353
+ * function that decides them), and the tenant's connection grants the platform delivered (#592,
7354
+ * the provision-time mirror of `connectorGrantLocal`).
7355
+ */
7356
+ declaredSeatsLocal(scopeId, connectionGrants) {
7357
+ return [
7358
+ ...[...this.moduleSchedules].flatMap(([modId, schedules]) => {
7359
+ const perms = new Set();
7360
+ for (const s of schedules)
7361
+ for (const p of s.permissions)
7362
+ perms.add(p);
7363
+ return [...perms].map((perm) => ({ subject: `system:${modId}`, relation: `granted:${perm}`, object: `scope:${scopeId}`, expires_at: null }));
7364
+ }),
7365
+ ...peerSeats(collectPeers(this.peerSources), scopeId).map((seat) => ({ ...seat, expires_at: null })),
7366
+ ...(connectionGrants ?? []).map((g) => ({
7367
+ subject: `connection:${g.connectionId}`,
7368
+ relation: `granted:${g.permission}`,
7369
+ object: `scope:${scopeId}`,
7370
+ expires_at: g.expiresAt ?? null,
7371
+ })),
7372
+ ];
7373
+ }
7374
+ /**
7375
+ * The ownerless reconcile (#2263): what a reconcile does for a vertical that keeps no owner of
7376
+ * record, so a scope provisioned before a version's declarations still receives them. It SEATS
7377
+ * `declaredSeatsLocal` and re-applies the directory's recorded-off positions in the same unit
7378
+ * (#1742), and does nothing else: no owner seat, no projection (roles, entitlements, identity
7379
+ * links, sealing keys and tenant tuples are left exactly as they are), and no entity-grant
7380
+ * shape top-up or retirement. Every seat is `seatScopeTuple`'s, so a revoked grant stays revoked
7381
+ * and a switched-off subject gets nothing. It can therefore only add what the running code
7382
+ * declares, and never takes anything away but a recorded OFF.
7383
+ *
7384
+ * Only a scope already provisioned for `tenantId` is seated (`servesTenant`, K-3): this never
7385
+ * provisions a scope, and never writes into another tenant's.
7386
+ */
7387
+ async seatScopeLocal(input) {
7388
+ const stub = this.scopeStub(input.scopeId);
7389
+ if (!(await stub.servesTenant(input.tenantId)))
7390
+ throw unknownScopeForTenant(input.tenantId, input.scopeId);
7391
+ const carry = recordedOffFromWire(input);
7392
+ await this.migrateAndRecord(input.scopeId);
7393
+ const switchedOff = await stub.seatTuples(this.declaredSeatsLocal(input.scopeId, input.connectionGrants), carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined);
7394
+ return carry ? { switchedOff } : {};
7395
+ }
6834
7396
  /**
6835
7397
  * Resolve an external identity from a scope's PROJECTED links (#406) — the CP-less
6836
7398
  * auth adapter's read path, the local equivalent of `HostAdmin.resolveIdentity` (same