@substrat-run/adapter-cloudflare 0.148.0 → 0.149.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,119 @@ 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
+ return sweepOrphanAttachments(store, scopeId, {
1484
+ rowless: (ids) => judge(false)(ids, now),
1485
+ condemn: judge(true),
1486
+ }, { now, ...(request.dryRun ? { dryRun: true } : {}), ...(request.limit !== undefined ? { limit: request.limit } : {}) });
1370
1487
  }
1371
1488
  registerModule(registration) {
1372
1489
  const manifest = moduleManifest.parse(registration.manifest);
@@ -1646,41 +1763,45 @@ export class CloudflareScopeHost {
1646
1763
  const store = await this.resolveAttachmentStore(tenantId);
1647
1764
  return this.buildAttachmentSurface({ principal }, tenantId, scopeId, store);
1648
1765
  }
1649
- async inspectUploadLink(tenantId, scopeId, secret) {
1766
+ async inspectUploadLink(tenantId, scopeId, secret, target, idempotencyKey) {
1650
1767
  await this.assertLive(tenantId, scopeId);
1651
1768
  await this.migrateAndRecord(scopeId);
1652
- return unwrapReply(await this.scopeStub(scopeId).uploadLinkInspectReply(secret, tenantId, scopeId)).view;
1769
+ return unwrapReply(await this.scopeStub(scopeId).uploadLinkInspectReply(secret, tenantId, scopeId, target, idempotencyKey)).view;
1653
1770
  }
1654
1771
  async redeemUploadLink(tenantId, scopeId, secret, input) {
1655
1772
  await this.assertLive(tenantId, scopeId);
1656
1773
  await this.migrateAndRecord(scopeId);
1657
1774
  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,
1775
+ // The target first (the kernel judges it before the state), so a wrong path never reads as
1776
+ // gone; a replay (#2315) is answered from the inspection and never reaches the write.
1777
+ const inspect = async () => unwrapReply(await stub.uploadLinkInspectReply(secret, tenantId, scopeId, input.entity, input.idempotencyKey));
1778
+ return redeemOrReplayUploadLink(inspect, input, async ({ view, mintedBy }) => {
1779
+ const store = await this.resolveAttachmentStore(tenantId);
1780
+ // A fresh id per request, so its key is its own: compensating a refusal can never remove
1781
+ // the bytes of the redemption that won the link.
1782
+ const id = ulid();
1783
+ const key = attachmentBlobKey(scopeId, id);
1784
+ const filename = view.filename ?? input.filename;
1785
+ // Recorded as it is judged: the essence, never a parameter or a stray space.
1786
+ const contentType = normalizeUploadMediaType(input.contentType);
1787
+ const record = attachmentRecord.parse({
1788
+ id,
1789
+ entity: view.entity,
1790
+ filename,
1791
+ contentType,
1792
+ size: input.body.byteLength,
1793
+ sha256: await attachmentSha256(input.body),
1794
+ visibility: view.visibility,
1795
+ createdBy: mintedBy,
1796
+ createdAt: new Date().toISOString(),
1662
1797
  });
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(),
1798
+ // Bytes first, row second, exactly as an upload: a refusal (size, type, a lost key, a link
1799
+ // someone else spent first) compensates the object away, and the link is unspent with it.
1800
+ // A lost reply keeps the bytes (`compensatingRefusal`), and a retry carrying the same
1801
+ // `Idempotency-Key` is answered as a replay (#2315).
1802
+ await store.put(key, input.body, { contentType });
1803
+ return unwrapReply(await compensatingRefusal(store, key, () => stub.attachmentRedeemUploadLinkReply(record, secret, tenantId, scopeId, input.idempotencyKey)));
1678
1804
  });
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
1805
  }
1685
1806
  async getSystemAttachments(moduleId, tenantId, scopeId) {
1686
1807
  // Reuse the system invoke door's module, tenant and lifecycle gate, and its rewind gate
@@ -1888,6 +2009,8 @@ export class CloudflareScopeHost {
1888
2009
  if (!existing)
1889
2010
  throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
1890
2011
  this.refuseUnusableScopeStores(dump.stores, existing.vertical);
2012
+ // #2314: the load replaces the scope's rows, so the orphan sweep is fenced first.
2013
+ await this.fenceOrphanSweep(scopeId, tenantId);
1891
2014
  // #1742: a dump from before a switch was pulled brings the module's grants back live, so
1892
2015
  // the directory's recorded-off modules go back off in the replay's own event. Only where
1893
2016
  // this is the scope's real store, as for the provision's seat above; a hosted scope is
@@ -2188,23 +2311,23 @@ export class CloudflareScopeHost {
2188
2311
  }
2189
2312
  return erasures;
2190
2313
  }
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`);
2314
+ /** A runner's driver, or the driver declared for an ambient binding. Never invented. */
2315
+ scopeStoreDriver(binding) {
2316
+ const driver = this.scopeStoreRunners.get(binding)?.driver ?? this.scopeStoreDrivers[binding];
2317
+ if (!driver) {
2318
+ throw substratError('unavailable', `scope store '${binding}' has no declared driver`);
2196
2319
  }
2197
- return kind;
2320
+ return driver;
2198
2321
  }
2199
2322
  /**
2200
2323
  * 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
2324
+ * binding that is not bound, a driver other than the bound one, or any store at all for
2202
2325
  * a destination with no vertical (no store object can be named for it, so loading would
2203
2326
  * drop them), or a destination on a vertical other than the one this host is pinned to.
2204
2327
  * `vertical` undefined is a caller that cannot read the destination here;
2205
2328
  * `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.
2329
+ * write. A store with no `driver` is refused: the dump contract requires one, and an
2330
+ * unparsed dump would otherwise skip the driver comparison.
2208
2331
  */
2209
2332
  refuseUnusableScopeStores(stores, vertical) {
2210
2333
  const listed = stores ?? [];
@@ -2221,12 +2344,12 @@ export class CloudflareScopeHost {
2221
2344
  if (!bound.has(store.binding)) {
2222
2345
  throw substratError('not_found', `dump names scope store '${store.binding}', which is not bound`);
2223
2346
  }
2224
- if (!store.kind) {
2225
- throw substratError('validation_failed', `dump's scope store '${store.binding}' names no kind`);
2347
+ if (!store.driver) {
2348
+ throw substratError('validation_failed', `dump's scope store '${store.binding}' names no driver`);
2226
2349
  }
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'}'`);
2350
+ const driver = this.scopeStoreRunners.get(store.binding)?.driver ?? this.scopeStoreDrivers[store.binding];
2351
+ if (store.driver !== driver) {
2352
+ throw substratError('conflict', `dump's scope store '${store.binding}' is driver '${store.driver}', and this host binds driver '${driver ?? 'undeclared'}'`);
2230
2353
  }
2231
2354
  }
2232
2355
  }
@@ -2244,7 +2367,7 @@ export class CloudflareScopeHost {
2244
2367
  const exported = await this.invokeReservedStore({ tenantId, vertical, scopeId, binding }, 'substrat.export');
2245
2368
  stores.push({
2246
2369
  binding,
2247
- kind: this.scopeStoreKind(binding),
2370
+ driver: this.scopeStoreDriver(binding),
2248
2371
  tables: exported.tables,
2249
2372
  });
2250
2373
  }
@@ -3018,9 +3141,10 @@ export class CloudflareScopeHost {
3018
3141
  // #119: a purge horizon's schedule runs its operation once per due entity, each its own
3019
3142
  // call and transaction — and the SCOPE decides which are due and runs them, so purge
3020
3143
  // authority is never an argument any stub can pass (`runPurgeSweep`).
3021
- const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance));
3144
+ // #2192: under the run's own id, so the purges and their deliveries join this run's line.
3145
+ const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance, invocationId));
3022
3146
  // The purges' events, delivered the way each invoke's own tail delivers them.
3023
- await this.drainExecutors(tenantId, scopeId, null);
3147
+ await this.drainExecutors(tenantId, scopeId, invocationId);
3024
3148
  stillDue = purgeStillDue(pass);
3025
3149
  const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
3026
3150
  report.errors.push(...outcome.errors);
@@ -4408,8 +4532,13 @@ export class CloudflareScopeHost {
4408
4532
  // The SQL migrations are stored apart from the manifest (#1764), so no read of the
4409
4533
  // version but the promote review's carries them.
4410
4534
  const split = splitManifestMigrations(manifestJson ?? null, 'push');
4535
+ // #2339: the digest a promote compares is the one derived from what is stored, never the pushed one.
4536
+ const migrationDigest = await publishedMigrationDigest({
4537
+ id: audited.id, migrationDigest: audited.migrationDigest, manifestJson: manifestJson ?? null, migrations: split.migrations,
4538
+ });
4411
4539
  await this.directory('insertVersion', {
4412
4540
  ...audited,
4541
+ migrationDigest,
4413
4542
  manifestJson: split.manifestJson,
4414
4543
  migrations: split.migrations,
4415
4544
  originJson: origin ? JSON.stringify(origin) : null,
@@ -4419,6 +4548,8 @@ export class CloudflareScopeHost {
4419
4548
  });
4420
4549
  await this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, {
4421
4550
  ...audited,
4551
+ // The digest the registry holds, which differs from the pushed one only for an unheld version.
4552
+ migrationDigest,
4422
4553
  ...(origin ? { origin } : {}),
4423
4554
  admission: selfAdmits ? 'admitted' : 'pending',
4424
4555
  });
@@ -4585,6 +4716,20 @@ export class CloudflareScopeHost {
4585
4716
  ], incoming.id, incoming, served || owned.length > 0
4586
4717
  ? await this.cp.provisionedVersionsOf(verticalSlug, served, owned.length > 0 ? owning.owner_tenant : null)
4587
4718
  : []);
4719
+ // #2333: the acknowledgements answer the review they were given against, or none at all.
4720
+ const reviewed = async (v) => ({
4721
+ id: v.id,
4722
+ permissionDigest: v.permission_digest,
4723
+ migrationDigest: v.migration_digest,
4724
+ migrations: (await this.cp.readVersionMigrations(v.id))?.migrations ?? null,
4725
+ });
4726
+ await assertPromoteReviewCurrent({
4727
+ ack,
4728
+ channel,
4729
+ baseId: outgoing?.id ?? null,
4730
+ migrationsChange: outgoing !== undefined && outgoing.migration_digest !== incoming.migration_digest,
4731
+ read: async () => ({ base: outgoing ? await reviewed(outgoing) : null, incoming: await reviewed(incoming) }),
4732
+ });
4588
4733
  // §4's checkpoints, at the moment of exposure. A first promotion has
4589
4734
  // nothing to diff against — the gate is about change, not existence.
4590
4735
  if (outgoing) {
@@ -4593,8 +4738,7 @@ export class CloudflareScopeHost {
4593
4738
  `${incoming.permission_digest}) — acknowledge it explicitly to promote`);
4594
4739
  }
4595
4740
  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`);
4741
+ throw substratError('conflict', migrationChangeRefusal(outgoing.migration_digest, incoming.migration_digest));
4598
4742
  }
4599
4743
  // #1705 PR 3: an export an installed consumer imports, dropped or re-versioned.
4600
4744
  if (!ack.exportBreak) {
@@ -4604,10 +4748,13 @@ export class CloudflareScopeHost {
4604
4748
  }
4605
4749
  }
4606
4750
  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({
4751
+ // #2333: a compare-and-set. Every gate above — the review binding, the digest checkpoints,
4752
+ // the export breaks — was judged against `outgoing`, across awaits another promote can run
4753
+ // in. The control plane moves the pointer, and writes the timeline row (what makes rollback a
4754
+ // choice among recorded moments, and `at` the PITR anchor a data rollback would rewind to),
4755
+ // only if the pointer still names `outgoing`; the promote that lost the race is refused as
4756
+ // stale and writes nothing.
4757
+ const moved = await this.cp.moveChannel(outgoing?.id ?? null, {
4611
4758
  id: ulid(),
4612
4759
  vertical_slug: verticalSlug,
4613
4760
  channel,
@@ -4616,6 +4763,8 @@ export class CloudflareScopeHost {
4616
4763
  actor,
4617
4764
  at: promotedAt,
4618
4765
  });
4766
+ if (!moved)
4767
+ throw promoteRaceRefusal(channel);
4619
4768
  // For a PRIVATE vertical, prod IS what the owner's apps run: re-point the
4620
4769
  // owning tenant's live scopes in the same act, so merge-to-main (push +
4621
4770
  // promote) is a complete deploy and a rollback promote reaches the running
@@ -4688,19 +4837,27 @@ export class CloudflareScopeHost {
4688
4837
  },
4689
4838
  bindScopeVersion: async (actor, tenantId, scopeId, versionId, opts) => {
4690
4839
  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
- }
4840
+ // #2347: a rebind judged against both lineages loses its race as stale, so its client reads
4841
+ // the review again; any other conditional bind keeps the refusal it always had.
4842
+ const lost = () => opts?.expectedServing
4843
+ ? rebindRaceRefusal(opts.expectedServing.vertical)
4844
+ : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
4845
+ if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId)
4846
+ throw lost();
4847
+ if (opts?.expectedServing && !await this.cp.servesNow(opts.expectedServing))
4848
+ throw lost();
4694
4849
  if (opts?.expectedErasureEpoch !== undefined && await this.cp.scopeErasureEpoch(tenantId, scopeId) !== opts.expectedErasureEpoch) {
4695
4850
  throw substratError('precondition_failed', 'scope erasure changed; reload the scope and retry');
4696
4851
  }
4697
4852
  const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
4853
+ // #2347: a route moving in the same write is judged with it, as `bindingImpact` is.
4854
+ const servingRef = opts?.servingRef;
4698
4855
  // #2178 first: no acknowledgement passes it, so none should be asked for ahead of it.
4699
- await this.assertBindingShapesKept(mapScope(scope), v);
4856
+ await this.assertBindingShapesKept(mapScope(scope), v, servingRef);
4700
4857
  // #1756: an export an app in this tenant imports, dropped or re-versioned by what this
4701
4858
  // scope would run. Before the snapshot, so a refused bind leaves nothing behind.
4702
4859
  if (!ack.exportBreak) {
4703
- const breaks = await this.bindBreaks(actor, mapScope(scope), v);
4860
+ const breaks = await this.bindBreaks(actor, mapScope(scope), v, servingRef);
4704
4861
  if (breaks.length > 0)
4705
4862
  throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
4706
4863
  }
@@ -4712,12 +4869,19 @@ export class CloudflareScopeHost {
4712
4869
  await this.snapshotScope(actor, tenantId, scopeId);
4713
4870
  }
4714
4871
  }
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');
4872
+ if (!await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug, opts?.expectedVersionId, opts?.expectedErasureEpoch, opts?.confirmMove, servingRef, opts?.expectedServing)) {
4873
+ // The erasure and copy-move conditions keep their own refusal: only a moved binding or a
4874
+ // moved serving version is the race a rebind's review answers.
4875
+ const now = await this.cp.getScopeRecord(tenantId, scopeId);
4876
+ const raced = (opts?.expectedVersionId !== undefined && (now?.vertical_version_id ?? null) !== opts.expectedVersionId)
4877
+ || (opts?.expectedServing !== undefined && !await this.cp.servesNow(opts.expectedServing));
4878
+ throw raced ? lost() : substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
4717
4879
  }
4718
- await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
4880
+ await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, servingRef === undefined ? null : { servingRef: scope.serving_ref ?? null }, {
4719
4881
  versionId, vertical: v.vertical_slug, version: v.version,
4882
+ ...(servingRef !== undefined ? { servingRef } : {}),
4720
4883
  ...(opts?.expectedVersionId !== undefined ? { expectedVersionId: opts.expectedVersionId } : {}),
4884
+ ...(opts?.expectedServing ? { expectedServing: opts.expectedServing } : {}),
4721
4885
  ...(ack.exportBreak ? { acknowledged: ack } : {}),
4722
4886
  });
4723
4887
  },
@@ -4898,7 +5062,7 @@ export class CloudflareScopeHost {
4898
5062
  return { rewindingTo: bookmark };
4899
5063
  }
4900
5064
  // #1819: the same rewind a dispatched vertical runs, holding what the scope switched off.
4901
- return this.rewindScopeLocal(scopeId, bookmark, { force: opts?.force });
5065
+ return this.rewindScopeLocal(scopeId, bookmark, { force: opts?.force, tenantId });
4902
5066
  },
4903
5067
  createOrg: async (actor, input) => {
4904
5068
  const parsed = createOrgInput.parse(input);
@@ -5050,43 +5214,63 @@ export class CloudflareScopeHost {
5050
5214
  createdAt: r.created_at,
5051
5215
  }));
5052
5216
  },
5053
- // -- store-kind releases, rollouts and holds (D-63, #2300) -------------------
5054
- recordStoreKindRelease: async (actor, input) => {
5217
+ // -- store-driver releases, rollouts and holds (D-63, #2300) -------------------
5218
+ recordStoreDriverRelease: async (actor, input) => {
5055
5219
  const at = new Date().toISOString();
5056
- const { record, created } = await this.directory('recordStoreKindRelease', { ...input, actor, at });
5220
+ const { record, created } = await this.directory('recordStoreDriverRelease', { ...input, actor, at });
5057
5221
  if (created)
5058
- await this.recordAdmin(actor, 'uploadStoreKindRelease', { tenantId: null }, null, record);
5222
+ await this.recordAdmin(actor, 'uploadStoreDriverRelease', { tenantId: null }, null, record);
5059
5223
  return record;
5060
5224
  },
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);
5225
+ getStoreDriverRelease: async (actor, driver, sha256) => {
5226
+ const record = await this.cp.getStoreDriverRelease(driver, sha256);
5227
+ await this.recordAccess(actor, 'getStoreDriverRelease', { tenantId: null }, { driver, sha256 }, record ? 1 : 0);
5064
5228
  return record;
5065
5229
  },
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);
5230
+ storeDriverState: async (actor, driver) => {
5231
+ const state = await this.cp.storeDriverState(driver);
5232
+ await this.recordAccess(actor, 'storeDriverState', { tenantId: null }, { driver }, state.rollouts.length);
5069
5233
  return state;
5070
5234
  },
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);
5235
+ storeDriverDeclarations: async (actor, driver) => {
5236
+ const declared = await this.directory('storeDriverDeclarations', driver);
5237
+ await this.recordAccess(actor, 'storeDriverDeclarations', { tenantId: null }, { driver }, declared.verticals.length);
5074
5238
  return declared;
5075
5239
  },
5076
- holdStoreKind: async (_actor, input) => this.directory('holdStoreKind', input),
5077
- releaseStoreKindHold: async (_actor, input) => {
5078
- await this.cp.releaseStoreKindHold(input);
5240
+ storeDriverRolloutPending: async (actor, input) => {
5241
+ const page = await this.directory('storeDriverRolloutPending', input);
5242
+ await this.recordAccess(actor, 'storeDriverRolloutPending', { tenantId: null }, { driver: input.driver, sha256: input.sha256, action: input.action }, page.verticals.length);
5243
+ return page;
5079
5244
  },
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);
5245
+ storeDriverRolloutWalked: async (actor, input) => {
5246
+ const walked = await this.directory('storeDriverRolloutWalked', input);
5247
+ await this.recordAccess(actor, 'storeDriverRolloutWalked', { tenantId: null }, { driver: input.driver, seq: input.seq }, walked.verticals.length);
5248
+ return walked;
5249
+ },
5250
+ holdStoreDriver: async (_actor, input) => this.directory('holdStoreDriver', input),
5251
+ releaseStoreDriverHold: async (_actor, input) => {
5252
+ await this.cp.releaseStoreDriverHold(input);
5253
+ },
5254
+ beginStoreDriverRollout: async (actor, input) => {
5255
+ const row = await this.directory('beginStoreDriverRollout', { ...input, actor, at: new Date().toISOString() });
5256
+ await this.recordAdmin(actor, input.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row);
5083
5257
  return row;
5084
5258
  },
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);
5259
+ finishStoreDriverRollout: async (actor, input) => {
5260
+ const row = await this.directory('finishStoreDriverRollout', { ...input, at: new Date().toISOString() });
5261
+ await this.recordAdmin(actor, row.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row);
5088
5262
  return row;
5089
5263
  },
5264
+ storeDriverRebuild: async (actor, driver) => {
5265
+ const rebuild = await this.cp.storeDriverRebuild(driver);
5266
+ await this.recordAccess(actor, 'storeDriverRebuild', { tenantId: null }, { driver }, rebuild ? 1 : 0);
5267
+ return rebuild;
5268
+ },
5269
+ claimStoreDriverRebuild: async (_actor, input) => this.directory('claimStoreDriverRebuild', { ...input, at: new Date().toISOString() }),
5270
+ advanceStoreDriverRebuild: async (_actor, input) => this.directory('advanceStoreDriverRebuild', { ...input, at: new Date().toISOString() }),
5271
+ recordStoreDriverRebuildRun: async (actor, entry) => {
5272
+ await this.recordAdmin(actor, 'rebuildStoreDriver', { tenantId: null }, null, entry);
5273
+ },
5090
5274
  listScopeTables: async (actor, tenantId, scopeId) => {
5091
5275
  // K-3 cross-check on the shared directory BEFORE reaching the scope DO: a pair
5092
5276
  // that does not resolve is unreachable, never another tenant's database.
@@ -6030,6 +6214,11 @@ export class CloudflareScopeHost {
6030
6214
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
6031
6215
  await this.recordAdmin(actor, action === 'mark' ? 'markScopeCopy' : 'clearScopeCopyMark', { tenantId, scopeId }, null, after);
6032
6216
  },
6217
+ /** #2314: one orphan-attachment pass over a scope, written after the vertical's own pass. */
6218
+ recordOrphanSweep: async (actor, entry) => {
6219
+ const { tenantId, scopeId, ...after } = orphanSweepAudit.parse(entry);
6220
+ await this.recordAdmin(actor, 'sweepOrphanAttachments', { tenantId, scopeId }, null, after);
6221
+ },
6033
6222
  accessLog: async (actor, filter) => {
6034
6223
  const rows = await this.cp.accessLog({
6035
6224
  actor: filter?.actor,