@substrat-run/adapter-cloudflare 0.148.0 → 0.150.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,10 @@
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, 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';
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, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
5
5
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
6
- import { attachmentBlobKey, 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';
6
+ import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
7
+ import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
7
8
  import { COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
8
9
  import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf } from '@substrat-run/kernel';
9
10
  import { PLATFORM_BINDING_PREFIX, scopeStoreBindingName, scopeStoreObjectName, STORE_CALL_URL, StoreError } from '@substrat-run/contracts';
@@ -320,7 +321,7 @@ export class CloudflareScopeHost {
320
321
  scopeStoreRunners;
321
322
  scopeStoreVertical;
322
323
  scopeStoreLifecycle;
323
- scopeStoreKinds;
324
+ scopeStoreDrivers;
324
325
  scopeStoreTransport;
325
326
  cp;
326
327
  servicePrincipals;
@@ -360,6 +361,7 @@ export class CloudflareScopeHost {
360
361
  blobStores;
361
362
  /** Worker-side attachment-bucket resolver (#473); the ambient per-tenant binding unless overridden (#1995). */
362
363
  attachmentBuckets;
364
+ orphanSweepClock;
363
365
  /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
364
366
  attachmentExtractors;
365
367
  attachmentTextBounds;
@@ -429,6 +431,7 @@ export class CloudflareScopeHost {
429
431
  this.tenantStores = options.tenantStores;
430
432
  this.blobStores = options.blobStores;
431
433
  this.attachmentBuckets = options.attachmentBuckets ?? ambientAttachmentBucket;
434
+ this.orphanSweepClock = options.orphanSweepClock ?? (() => new Date().toISOString());
432
435
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
433
436
  this.attachmentExtractors = options.attachmentExtractors ?? [];
434
437
  this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
@@ -437,7 +440,7 @@ export class CloudflareScopeHost {
437
440
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
438
441
  this.scopeNs = options.scope;
439
442
  this.scopeStoreVertical = options.scopeStores?.vertical;
440
- this.scopeStoreKinds = options.scopeStores?.kinds ?? {};
443
+ this.scopeStoreDrivers = options.scopeStores?.drivers ?? {};
441
444
  this.scopeStoreTransport = scopeStoreTransportOf(options);
442
445
  this.scopeStoreRunners = scopeStoreRunnersOf(options.scopeStores);
443
446
  this.scopeStoreLifecycle = scopeStoreLifecycleOf(this.scopeStoreRunners);
@@ -1054,6 +1057,8 @@ export class CloudflareScopeHost {
1054
1057
  opts) {
1055
1058
  if (opts?.markCopy)
1056
1059
  assertCopyLineage(opts.markCopy);
1060
+ // #2314: a load replaces the scope's rows, so the orphan sweep is fenced first, as for a rewind.
1061
+ await this.fenceOrphanSweep(scopeId, opts?.tenantId);
1057
1062
  const carry = opts ? recordedOffFromWire(opts) : undefined;
1058
1063
  const load = {
1059
1064
  switchOff: carry ? { at: new Date().toISOString(), ...carry } : undefined,
@@ -1366,7 +1371,127 @@ export class CloudflareScopeHost {
1366
1371
  * run nothing until the switch is back in the scope. See `rewindHolding`.
1367
1372
  */
1368
1373
  async rewindScopeLocal(scopeId, bookmark, opts) {
1369
- return this.rewindHolding(scopeId, () => this.scopeStub(scopeId).rewindToBookmark(bookmark, opts));
1374
+ // #2314: the orphan sweep's fence first. The rewind can take rows away that a later restore
1375
+ // brings back, so their bytes must outlive the PITR window; no fence, no rewind.
1376
+ try {
1377
+ await this.fenceOrphanSweep(scopeId, opts?.tenantId);
1378
+ }
1379
+ catch (err) {
1380
+ throw new Error(`${REWIND_REFUSED}${err instanceof Error ? err.message : String(err)}`);
1381
+ }
1382
+ return this.rewindHolding(scopeId, () => this.scopeStub(scopeId).rewindToBookmark(bookmark, { force: opts?.force }));
1383
+ }
1384
+ /**
1385
+ * #2314: write the orphan sweep's restore fence for a scope whose state is about to be
1386
+ * replaced, into the tenant's attachment bucket, so no pass deletes its bytes while a row the
1387
+ * replacement takes away can still be restored. The tenant is the one named, else the scope's
1388
+ * own provision receipt. A scope whose tenant is neither holds no row the replacement could
1389
+ * take from it unless it holds an attachment row (a fresh scope, a clean-room preview), and
1390
+ * only then is it refused, because its bucket cannot be found. A tenant this script holds no
1391
+ * bucket for has no bytes here, so nothing is fenced. Throws when the fence cannot be written:
1392
+ * the caller refuses the replacement.
1393
+ */
1394
+ async fenceOrphanSweep(scopeId, named) {
1395
+ let tenantId = named ?? null;
1396
+ if (!tenantId) {
1397
+ const facts = await this.scopeStub(scopeId).sweepFenceFacts();
1398
+ if (!facts.tenant && !facts.holdsAttachments)
1399
+ return;
1400
+ tenantId = facts.tenant;
1401
+ }
1402
+ if (!tenantId) {
1403
+ throw new Error(`the tenant of scope ${scopeId} is unknown (no provision receipt, and none was named), so the ` +
1404
+ `orphan-attachment sweep's restore fence (#2314) cannot be written. Reconcile the scope, or ` +
1405
+ `name its tenant, then retry`);
1406
+ }
1407
+ const bucket = await this.attachmentBuckets(tenantId);
1408
+ if (!bucket)
1409
+ return;
1410
+ try {
1411
+ await fenceAttachmentSweep(r2TenantBlobStore(bucket), scopeId, this.orphanSweepClock());
1412
+ }
1413
+ catch (err) {
1414
+ throw new Error(`the orphan-attachment sweep's restore fence (#2314) could not be written for scope ${scopeId}: ` +
1415
+ `${err instanceof Error ? err.message : String(err)}. Nothing was replaced; retry once the attachment bucket answers`);
1416
+ }
1417
+ }
1418
+ /**
1419
+ * #2314: one orphan-sweep pass over a scope's attachment bytes, judged by THIS deployment's
1420
+ * copy of the scope (`sweepOrphanAttachments` in the kernel has the guards). The caller is the
1421
+ * platform, and only for the deployment that serves the scope: the copy refuses to judge if it
1422
+ * is not the scope's provisioned primary, but a stale copy in an older version's script cannot
1423
+ * tell that it is stale.
1424
+ */
1425
+ async sweepOrphanAttachments(tenantId, scopeId, request = {}) {
1426
+ // With a directory, it is the word on the scope, and it is read BEFORE the object is addressed,
1427
+ // since addressing a reaped or never-held object would wake an empty one: this tenant's, active,
1428
+ // a primary, and with no other script's copy that may hold rows this one lacks. A CP-less
1429
+ // deployment has none: its scope's own storage is the word (receipt, lifecycle, copy markers,
1430
+ // in the DO), and the copy ledger is the caller's to have read.
1431
+ let vouched = false;
1432
+ if (!this.cpLess) {
1433
+ const record = await this.cp.getScopeRecord(tenantId, scopeId);
1434
+ if (!record)
1435
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1436
+ if (record.status !== 'active') {
1437
+ throw substratError('conflict', `refused: scope ${scopeId} is ${record.status}, and only an active scope's bytes are swept`);
1438
+ }
1439
+ if (!isPrimaryScopeRow(record)) {
1440
+ throw substratError('conflict', `refused: scope ${scopeId} is a copy, and a copy may not judge orphan attachments`);
1441
+ }
1442
+ // Every ledger entry of the scope, page by page, so no page can hide one: a pass needs all of
1443
+ // them settled (`SETTLED_COPY_STATES` is an allowlist), and stops at the first that is not.
1444
+ let after;
1445
+ for (;;) {
1446
+ const page = (await this.cp.listScopeScriptCopies({ tenantId, scopeId, limit: 100, ...(after ? { after } : {}) })).map(scopeScriptCopyOf);
1447
+ const unsettled = unsettledCopyRefusal(scopeId, page);
1448
+ if (unsettled)
1449
+ throw unsettled;
1450
+ const last = page[page.length - 1];
1451
+ if (page.length < 100 || !last)
1452
+ break;
1453
+ after = { scriptRef: last.scriptRef, moveId: last.moveId };
1454
+ }
1455
+ // The tenant's status too, as every door into a scope asks.
1456
+ await this.assertLive(tenantId, scopeId);
1457
+ vouched = true;
1458
+ }
1459
+ else if (request.copiesSettled !== true) {
1460
+ throw substratError('conflict', `refused: this deployment cannot read the copy ledger, so a pass over scope ${scopeId} needs its caller ` +
1461
+ 'to have found no unsettled copy of it (copiesSettled)');
1462
+ }
1463
+ const stub = this.scopeStub(scopeId);
1464
+ const now = this.orphanSweepClock();
1465
+ const expected = request.expectedVersionId;
1466
+ const judge = (condemn) => async (ids, at) => unwrapReply(await stub.orphanAttachmentsReply([...ids], at, tenantId, condemn, vouched, expected));
1467
+ // The scope's copy gives its verdict first, with no ids: every refusal it makes, and whether this
1468
+ // script is the one the platform routed the pass to, in the pass's own mode. So a destructive
1469
+ // pass routed to the wrong script is refused even when there is nothing to condemn.
1470
+ await judge(request.dryRun !== true)([], now);
1471
+ // A script with no attachment store for the tenant holds none of its bytes: a vertical whose
1472
+ // modules keep no files. Answered as that, marked, rather than the throw `resolveAttachmentStore`
1473
+ // makes for a write, so the platform's pass can tell it from a failure and from a store that held
1474
+ // nothing. A scope that holds an attachment row does keep files, so its missing store is a
1475
+ // misconfiguration, and it fails.
1476
+ const bucket = await this.attachmentBuckets(tenantId);
1477
+ if (!bucket) {
1478
+ if ((await stub.sweepFenceFacts()).holdsAttachments)
1479
+ throw missingAttachmentStore(tenantId, scopeId);
1480
+ return { listed: 0, candidates: 0, orphans: 0, deleted: 0, keys: [], dryRun: request.dryRun === true, bucket: 'none' };
1481
+ }
1482
+ const store = r2TenantBlobStore(bucket);
1483
+ const licence = (op) => async (ids = []) => unwrapReply(await stub.orphanSweepLicenceReply(op, [...ids], now, tenantId, vouched, expected));
1484
+ return sweepOrphanAttachments(store, scopeId, {
1485
+ rowless: (ids) => judge(false)(ids, now),
1486
+ condemn: judge(true),
1487
+ license: async (ids) => {
1488
+ await licence('record')(ids);
1489
+ },
1490
+ licensed: () => licence('read')(),
1491
+ consume: async (ids) => {
1492
+ await licence('consume')(ids);
1493
+ },
1494
+ }, { now, ...(request.dryRun ? { dryRun: true } : {}), ...(request.limit !== undefined ? { limit: request.limit } : {}) });
1370
1495
  }
1371
1496
  registerModule(registration) {
1372
1497
  const manifest = moduleManifest.parse(registration.manifest);
@@ -1646,41 +1771,45 @@ export class CloudflareScopeHost {
1646
1771
  const store = await this.resolveAttachmentStore(tenantId);
1647
1772
  return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
1648
1773
  }
1649
- async inspectUploadLink(tenantId, scopeId, secret) {
1774
+ async inspectUploadLink(tenantId, scopeId, secret, target, idempotencyKey) {
1650
1775
  await this.assertLive(tenantId, scopeId);
1651
1776
  await this.migrateAndRecord(scopeId);
1652
- return unwrapReply(await this.scopeStub(scopeId).uploadLinkInspectReply(secret, tenantId, scopeId)).view;
1777
+ return unwrapReply(await this.scopeStub(scopeId).uploadLinkInspectReply(secret, tenantId, scopeId, target, idempotencyKey)).view;
1653
1778
  }
1654
1779
  async redeemUploadLink(tenantId, scopeId, secret, input) {
1655
1780
  await this.assertLive(tenantId, scopeId);
1656
1781
  await this.migrateAndRecord(scopeId);
1657
1782
  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,
1783
+ // The target first (the kernel judges it before the state), so a wrong path never reads as
1784
+ // gone; a replay (#2315) is answered from the inspection and never reaches the write.
1785
+ const inspect = async () => unwrapReply(await stub.uploadLinkInspectReply(secret, tenantId, scopeId, input.entity, input.idempotencyKey));
1786
+ return redeemOrReplayUploadLink(inspect, input, async ({ view, mintedBy }) => {
1787
+ const store = await this.resolveAttachmentStore(tenantId);
1788
+ // A fresh id per request, so its key is its own: compensating a refusal can never remove
1789
+ // the bytes of the redemption that won the link.
1790
+ const id = ulid();
1791
+ const key = attachmentBlobKey(scopeId, id);
1792
+ const filename = view.filename ?? input.filename;
1793
+ // Recorded as it is judged: the essence, never a parameter or a stray space.
1794
+ const contentType = normalizeUploadMediaType(input.contentType);
1795
+ const record = attachmentRecord.parse({
1796
+ id,
1797
+ entity: view.entity,
1798
+ filename,
1799
+ contentType,
1800
+ size: input.body.byteLength,
1801
+ sha256: await attachmentSha256(input.body),
1802
+ visibility: view.visibility,
1803
+ createdBy: mintedBy,
1804
+ createdAt: new Date().toISOString(),
1662
1805
  });
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(),
1806
+ // Bytes first, row second, exactly as an upload: a refusal (size, type, a lost key, a link
1807
+ // someone else spent first) compensates the object away, and the link is unspent with it.
1808
+ // A lost reply keeps the bytes (`compensatingRefusal`), and a retry carrying the same
1809
+ // `Idempotency-Key` is answered as a replay (#2315).
1810
+ await store.put(key, input.body, { contentType });
1811
+ return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentRedeemUploadLinkReply(record, secret, tenantId, scopeId, input.idempotencyKey)));
1678
1812
  });
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
1813
  }
1685
1814
  async getSystemAttachments(moduleId, tenantId, scopeId) {
1686
1815
  // Reuse the system invoke door's module, tenant and lifecycle gate, and its rewind gate
@@ -1888,6 +2017,8 @@ export class CloudflareScopeHost {
1888
2017
  if (!existing)
1889
2018
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1890
2019
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2020
+ // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2021
+ await this.fenceOrphanSweep(scopeId, tenantId);
1891
2022
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
1892
2023
  // the directory's recorded-off modules go back off in the replay's own event. Only where
1893
2024
  // this is the scope's real store, as for the provision's seat above; a hosted scope is
@@ -2188,23 +2319,23 @@ export class CloudflareScopeHost {
2188
2319
  }
2189
2320
  return erasures;
2190
2321
  }
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`);
2322
+ /** A runner's driver, or the driver declared for an ambient binding. Never invented. */
2323
+ scopeStoreDriver(binding) {
2324
+ const driver = this.scopeStoreRunners.get(binding)?.driver ?? this.scopeStoreDrivers[binding];
2325
+ if (!driver) {
2326
+ throw substratError('unavailable', `scope store '${binding}' has no declared driver`);
2196
2327
  }
2197
- return kind;
2328
+ return driver;
2198
2329
  }
2199
2330
  /**
2200
2331
  * 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
2332
+ * binding that is not bound, a driver other than the bound one, or any store at all for
2202
2333
  * a destination with no vertical (no store object can be named for it, so loading would
2203
2334
  * drop them), or a destination on a vertical other than the one this host is pinned to.
2204
2335
  * `vertical` undefined is a caller that cannot read the destination here;
2205
2336
  * `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.
2337
+ * write. A store with no `driver` is refused: the dump contract requires one, and an
2338
+ * unparsed dump would otherwise skip the driver comparison.
2208
2339
  */
2209
2340
  refuseUnusableScopeStores(stores, vertical) {
2210
2341
  const listed = stores ?? [];
@@ -2221,12 +2352,12 @@ export class CloudflareScopeHost {
2221
2352
  if (!bound.has(store.binding)) {
2222
2353
  throw substratError('not_found', `dump names scope store '${store.binding}', which is not bound`);
2223
2354
  }
2224
- if (!store.kind) {
2225
- throw substratError('validation_failed', `dump's scope store '${store.binding}' names no kind`);
2355
+ if (!store.driver) {
2356
+ throw substratError('validation_failed', `dump's scope store '${store.binding}' names no driver`);
2226
2357
  }
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'}'`);
2358
+ const driver = this.scopeStoreRunners.get(store.binding)?.driver ?? this.scopeStoreDrivers[store.binding];
2359
+ if (store.driver !== driver) {
2360
+ throw substratError('conflict', `dump's scope store '${store.binding}' is driver '${store.driver}', and this host binds driver '${driver ?? 'undeclared'}'`);
2230
2361
  }
2231
2362
  }
2232
2363
  }
@@ -2244,7 +2375,7 @@ export class CloudflareScopeHost {
2244
2375
  const exported = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.export');
2245
2376
  stores.push({
2246
2377
  binding,
2247
- kind: this.scopeStoreKind(binding),
2378
+ driver: this.scopeStoreDriver(binding),
2248
2379
  tables: exported.tables,
2249
2380
  });
2250
2381
  }
@@ -3018,9 +3149,10 @@ export class CloudflareScopeHost {
3018
3149
  // #119: a purge horizon's schedule runs its operation once per due entity, each its own
3019
3150
  // call and transaction — and the SCOPE decides which are due and runs them, so purge
3020
3151
  // authority is never an argument any stub can pass (`runPurgeSweep`).
3021
- const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance));
3152
+ // #2192: under the run's own id, so the purges and their deliveries join this run's line.
3153
+ const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId));
3022
3154
  // The purges' events, delivered the way each invoke's own tail delivers them.
3023
- await this.drainExecutors(tenantId, scopeId, null);
3155
+ await this.drainExecutors(tenantId, scopeId, invocationId);
3024
3156
  stillDue = purgeStillDue(pass);
3025
3157
  const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
3026
3158
  report.errors.push(...outcome.errors);
@@ -4408,8 +4540,13 @@ export class CloudflareScopeHost {
4408
4540
  // The SQL migrations are stored apart from the manifest (#1764), so no read of the
4409
4541
  // version but the promote review's carries them.
4410
4542
  const split = splitManifestMigrations(manifestJson ?? null, 'push');
4543
+ // #2339: the digest a promote compares is the one derived from what is stored, never the pushed one.
4544
+ const migrationDigest = await publishedMigrationDigest({
4545
+ id: audited.id, migrationDigest: audited.migrationDigest, manifestJson: manifestJson ?? null, migrations: split.migrations,
4546
+ });
4411
4547
  await this.directory('insertVersion', {
4412
4548
  ...audited,
4549
+ migrationDigest,
4413
4550
  manifestJson: split.manifestJson,
4414
4551
  migrations: split.migrations,
4415
4552
  originJson: origin ? JSON.stringify(origin) : null,
@@ -4419,6 +4556,8 @@ export class CloudflareScopeHost {
4419
4556
  });
4420
4557
  await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
4421
4558
  ...audited,
4559
+ // The digest the registry holds, which differs from the pushed one only for an unheld version.
4560
+ migrationDigest,
4422
4561
  ...(origin ? { origin } : {}),
4423
4562
  admission: selfAdmits ? 'admitted' : 'pending',
4424
4563
  });
@@ -4585,6 +4724,20 @@ export class CloudflareScopeHost {
4585
4724
  ], incoming.id, incoming, served || owned.length > 0
4586
4725
  ? await this.cp.provisionedVersionsOf(verticalSlug, served, owned.length > 0 ? owning.owner_tenant : null)
4587
4726
  : []);
4727
+ // #2333: the acknowledgements answer the review they were given against, or none at all.
4728
+ const reviewed = async (v) => ({
4729
+ id: v.id,
4730
+ permissionDigest: v.permission_digest,
4731
+ migrationDigest: v.migration_digest,
4732
+ migrations: (await this.cp.readVersionMigrations(v.id))?.migrations ?? null,
4733
+ });
4734
+ await assertPromoteReviewCurrent({
4735
+ ack,
4736
+ channel,
4737
+ baseId: outgoing?.id ?? null,
4738
+ migrationsChange: outgoing !== undefined && outgoing.migration_digest !== incoming.migration_digest,
4739
+ read: async () => ({ base: outgoing ? await reviewed(outgoing) : null, incoming: await reviewed(incoming) }),
4740
+ });
4588
4741
  // §4's checkpoints, at the moment of exposure. A first promotion has
4589
4742
  // nothing to diff against — the gate is about change, not existence.
4590
4743
  if (outgoing) {
@@ -4593,8 +4746,7 @@ export class CloudflareScopeHost {
4593
4746
  `${incoming.permission_digest}) — acknowledge it explicitly to promote`);
4594
4747
  }
4595
4748
  if (outgoing.migration_digest !== incoming.migration_digest && !ack.migrationChange) {
4596
- throw substratError('conflict', `promotion changes migrations (${outgoing.migration_digest} → ` +
4597
- `${incoming.migration_digest}) — acknowledge it explicitly to promote`);
4749
+ throw substratError('conflict', migrationChangeRefusal(outgoing.migration_digest, incoming.migration_digest));
4598
4750
  }
4599
4751
  // #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
4600
4752
  if (!ack.exportBreak) {
@@ -4604,10 +4756,13 @@ export class CloudflareScopeHost {
4604
4756
  }
4605
4757
  }
4606
4758
  const promotedAt = new Date().toISOString();
4607
- await this.cp.setChannel(verticalSlug, channel, versionId, promotedAt);
4608
- // The timeline row: what makes rollback a choice among recorded moments, and
4609
- // `at` the PITR anchor a data rollback would rewind to.
4610
- await this.cp.insertChannelHistory({
4759
+ // #2333: a compare-and-set. Every gate above — the review binding, the digest checkpoints,
4760
+ // the export breaks — was judged against `outgoing`, across awaits another promote can run
4761
+ // in. The control plane moves the pointer, and writes the timeline row (what makes rollback a
4762
+ // choice among recorded moments, and `at` the PITR anchor a data rollback would rewind to),
4763
+ // only if the pointer still names `outgoing`; the promote that lost the race is refused as
4764
+ // stale and writes nothing.
4765
+ const moved = await this.cp.moveChannel(outgoing?.id ?? null, {
4611
4766
  id: ulid(),
4612
4767
  vertical_slug: verticalSlug,
4613
4768
  channel,
@@ -4616,6 +4771,8 @@ export class CloudflareScopeHost {
4616
4771
  actor,
4617
4772
  at: promotedAt,
4618
4773
  });
4774
+ if (!moved)
4775
+ throw promoteRaceRefusal(channel);
4619
4776
  // For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
4620
4777
  // owning tenant's live scopes in the same act, so merge-to-main (push +
4621
4778
  // promote) is a complete deploy and a rollback promote reaches the running
@@ -4688,19 +4845,27 @@ export class CloudflareScopeHost {
4688
4845
  },
4689
4846
  bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
4690
4847
  const { v, scope } = await bindTarget(tenantId, scopeId, versionId);
4691
- if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId) {
4692
- throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
4693
- }
4848
+ // #2347: a rebind judged against both lineages loses its race as stale, so its client reads
4849
+ // the review again; any other conditional bind keeps the refusal it always had.
4850
+ const lost = () => opts?.expectedServing
4851
+ ? rebindRaceRefusal(opts.expectedServing.vertical)
4852
+ : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
4853
+ if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId)
4854
+ throw lost();
4855
+ if (opts?.expectedServing && !await this.cp.servesNow(opts.expectedServing))
4856
+ throw lost();
4694
4857
  if (opts?.expectedErasureEpoch !== undefined && await this.cp.scopeErasureEpoch(tenantId, scopeId) !== opts.expectedErasureEpoch) {
4695
4858
  throw substratError('precondition_failed', 'scope erasure changed; reload the scope and retry');
4696
4859
  }
4697
4860
  const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
4861
+ // #2347: a route moving in the same write is judged with it, as `bindingImpact` is.
4862
+ const servingRef = opts?.servingRef;
4698
4863
  // #2178 first: no acknowledgement passes it, so none should be asked for ahead of it.
4699
- await this.assertBindingShapesKept(mapScope(scope), v);
4864
+ await this.assertBindingShapesKept(mapScope(scope), v, servingRef);
4700
4865
  // #1756: an export an app in this tenant imports, dropped or re-versioned by what this
4701
4866
  // scope would run. Before the snapshot, so a refused bind leaves nothing behind.
4702
4867
  if (!ack.exportBreak) {
4703
- const breaks = await this.bindBreaks(actor, mapScope(scope), v);
4868
+ const breaks = await this.bindBreaks(actor, mapScope(scope), v, servingRef);
4704
4869
  if (breaks.length > 0)
4705
4870
  throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
4706
4871
  }
@@ -4712,12 +4877,19 @@ export class CloudflareScopeHost {
4712
4877
  await this.snapshotScope(actor, tenantId, scopeId);
4713
4878
  }
4714
4879
  }
4715
- if (!await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug, opts?.expectedVersionId, opts?.expectedErasureEpoch, opts?.confirmMove)) {
4716
- throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
4880
+ if (!await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug, opts?.expectedVersionId, opts?.expectedErasureEpoch, opts?.confirmMove, servingRef, opts?.expectedServing)) {
4881
+ // The erasure and copy-move conditions keep their own refusal: only a moved binding or a
4882
+ // moved serving version is the race a rebind's review answers.
4883
+ const now = await this.cp.getScopeRecord(tenantId, scopeId);
4884
+ const raced = (opts?.expectedVersionId !== undefined && (now?.vertical_version_id ?? null) !== opts.expectedVersionId)
4885
+ || (opts?.expectedServing !== undefined && !await this.cp.servesNow(opts.expectedServing));
4886
+ throw raced ? lost() : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
4717
4887
  }
4718
- await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
4888
+ await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, servingRef === undefined ? null : { servingRef: scope.serving_ref ?? null }, {
4719
4889
  versionId, vertical: v.vertical_slug, version: v.version,
4890
+ ...(servingRef !== undefined ? { servingRef } : {}),
4720
4891
  ...(opts?.expectedVersionId !== undefined ? { expectedVersionId: opts.expectedVersionId } : {}),
4892
+ ...(opts?.expectedServing ? { expectedServing: opts.expectedServing } : {}),
4721
4893
  ...(ack.exportBreak ? { acknowledged: ack } : {}),
4722
4894
  });
4723
4895
  },
@@ -4898,7 +5070,7 @@ export class CloudflareScopeHost {
4898
5070
  return { rewindingTo: bookmark };
4899
5071
  }
4900
5072
  // #1819: the same rewind a dispatched vertical runs, holding what the scope switched off.
4901
- return this.rewindScopeLocal(scopeId, bookmark, { force: opts?.force });
5073
+ return this.rewindScopeLocal(scopeId, bookmark, { force: opts?.force, tenantId });
4902
5074
  },
4903
5075
  createOrg: async (actor, input) => {
4904
5076
  const parsed = createOrgInput.parse(input);
@@ -5050,43 +5222,63 @@ export class CloudflareScopeHost {
5050
5222
  createdAt: r.created_at,
5051
5223
  }));
5052
5224
  },
5053
- // -- store-kind releases, rollouts and holds (D-63, #2300) -------------------
5054
- recordStoreKindRelease: async (actor, input) => {
5225
+ // -- store-driver releases, rollouts and holds (D-63, #2300) -------------------
5226
+ recordStoreDriverRelease: async (actor, input) => {
5055
5227
  const at = new Date().toISOString();
5056
- const { record, created } = await this.directory('recordStoreKindRelease', { ...input, actor, at });
5228
+ const { record, created } = await this.directory('recordStoreDriverRelease', { ...input, actor, at });
5057
5229
  if (created)
5058
- await this.recordAdmin(actor, 'uploadStoreKindRelease', { tenantId: null }, null, record);
5230
+ await this.recordAdmin(actor, 'uploadStoreDriverRelease', { tenantId: null }, null, record);
5059
5231
  return record;
5060
5232
  },
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);
5233
+ getStoreDriverRelease: async (actor, driver, sha256) => {
5234
+ const record = await this.cp.getStoreDriverRelease(driver, sha256);
5235
+ await this.recordAccess(actor, 'getStoreDriverRelease', { tenantId: null }, { driver, sha256 }, record ? 1 : 0);
5064
5236
  return record;
5065
5237
  },
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);
5238
+ storeDriverState: async (actor, driver) => {
5239
+ const state = await this.cp.storeDriverState(driver);
5240
+ await this.recordAccess(actor, 'storeDriverState', { tenantId: null }, { driver }, state.rollouts.length);
5069
5241
  return state;
5070
5242
  },
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);
5243
+ storeDriverDeclarations: async (actor, driver) => {
5244
+ const declared = await this.directory('storeDriverDeclarations', driver);
5245
+ await this.recordAccess(actor, 'storeDriverDeclarations', { tenantId: null }, { driver }, declared.verticals.length);
5074
5246
  return declared;
5075
5247
  },
5076
- holdStoreKind: async (_actor, input) => this.directory('holdStoreKind', input),
5077
- releaseStoreKindHold: async (_actor, input) => {
5078
- await this.cp.releaseStoreKindHold(input);
5248
+ storeDriverRolloutPending: async (actor, input) => {
5249
+ const page = await this.directory('storeDriverRolloutPending', input);
5250
+ await this.recordAccess(actor, 'storeDriverRolloutPending', { tenantId: null }, { driver: input.driver, sha256: input.sha256, action: input.action }, page.verticals.length);
5251
+ return page;
5252
+ },
5253
+ storeDriverRolloutWalked: async (actor, input) => {
5254
+ const walked = await this.directory('storeDriverRolloutWalked', input);
5255
+ await this.recordAccess(actor, 'storeDriverRolloutWalked', { tenantId: null }, { driver: input.driver, seq: input.seq }, walked.verticals.length);
5256
+ return walked;
5257
+ },
5258
+ holdStoreDriver: async (_actor, input) => this.directory('holdStoreDriver', input),
5259
+ releaseStoreDriverHold: async (_actor, input) => {
5260
+ await this.cp.releaseStoreDriverHold(input);
5079
5261
  },
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);
5262
+ beginStoreDriverRollout: async (actor, input) => {
5263
+ const row = await this.directory('beginStoreDriverRollout', { ...input, actor, at: new Date().toISOString() });
5264
+ await this.recordAdmin(actor, input.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row);
5083
5265
  return row;
5084
5266
  },
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);
5267
+ finishStoreDriverRollout: async (actor, input) => {
5268
+ const row = await this.directory('finishStoreDriverRollout', { ...input, at: new Date().toISOString() });
5269
+ await this.recordAdmin(actor, row.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row);
5088
5270
  return row;
5089
5271
  },
5272
+ storeDriverRebuild: async (actor, driver) => {
5273
+ const rebuild = await this.cp.storeDriverRebuild(driver);
5274
+ await this.recordAccess(actor, 'storeDriverRebuild', { tenantId: null }, { driver }, rebuild ? 1 : 0);
5275
+ return rebuild;
5276
+ },
5277
+ claimStoreDriverRebuild: async (_actor, input) => this.directory('claimStoreDriverRebuild', { ...input, at: new Date().toISOString() }),
5278
+ advanceStoreDriverRebuild: async (_actor, input) => this.directory('advanceStoreDriverRebuild', { ...input, at: new Date().toISOString() }),
5279
+ recordStoreDriverRebuildRun: async (actor, entry) => {
5280
+ await this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry);
5281
+ },
5090
5282
  listScopeTables: async (actor, tenantId, scopeId) => {
5091
5283
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
5092
5284
  // that does not resolve is unreachable, never another tenant's database.
@@ -6030,6 +6222,11 @@ export class CloudflareScopeHost {
6030
6222
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
6031
6223
  await this.recordAdmin(actor, action === 'mark' ? 'markScopeCopy' : 'clearScopeCopyMark', { tenantId, scopeId }, null, after);
6032
6224
  },
6225
+ /** #2314: one orphan-attachment pass over a scope, written after the vertical's own pass. */
6226
+ recordOrphanSweep: async (actor, entry) => {
6227
+ const { tenantId, scopeId, ...after } = orphanSweepAudit.parse(entry);
6228
+ await this.recordAdmin(actor, 'sweepOrphanAttachments', { tenantId, scopeId }, null, after);
6229
+ },
6033
6230
  accessLog: async (actor, filter) => {
6034
6231
  const rows = await this.cp.accessLog({
6035
6232
  actor: filter?.actor,