@substrat-run/adapter-cloudflare 0.141.0 → 0.143.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { env as ambientEnv } from 'cloudflare:workers';
2
2
  import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
3
3
  import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
4
- import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, exportsOfManifestJson, 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, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, 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, 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, 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';
5
5
  import { normalizeHostname, toRouteTarget } from './route-resolver.js';
6
- import { attachmentBlobKey, attachmentSha256, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, resolveScopeRecord, ulid, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, 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, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, bindExportBreaksOf, bindExportBreakRefusal, collectPeers, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, registerTrashTargets, memberAddedAudit, shapeTopUpBatch, } from '@substrat-run/kernel';
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, collectPeers, 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
8
  import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased } from '@substrat-run/kernel';
9
9
  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';
@@ -314,6 +314,7 @@ export class CloudflareScopeHost {
314
314
  peerSwitchDelegation;
315
315
  /** #1705 PR 3: the replay lever's reach into the deployment serving a consumer scope. */
316
316
  importCursorDelegation;
317
+ capabilityDelegation;
317
318
  /**
318
319
  * #1819: the last read of the rewind hold, and when it was taken (before the read went out).
319
320
  * Per instance, so per pass: the host is built per request, and never outlives one.
@@ -359,6 +360,7 @@ export class CloudflareScopeHost {
359
360
  this.lifecycleDelegation = options.lifecycleDelegation;
360
361
  this.peerSwitchDelegation = options.peerSwitchDelegation;
361
362
  this.importCursorDelegation = options.importCursorDelegation;
363
+ this.capabilityDelegation = options.capabilityDelegation;
362
364
  this.admin = this.buildAdmin();
363
365
  }
364
366
  // -- registration mechanics (validation only) -----------------------------
@@ -770,7 +772,7 @@ export class CloudflareScopeHost {
770
772
  async listPlatformRequests(tenantId, scopeId) {
771
773
  await this.validateScopeAccess(tenantId, scopeId);
772
774
  await this.migrateAndRecord(scopeId);
773
- // Tolerant (#1588): one undecodable row comes back naming why, never throws for the list.
775
+ // Tolerant (#1588, #1637): an undecodable row comes back naming why, never throws for the list.
774
776
  return (await this.scopeStub(scopeId).pendingPlatformRequests()).map(platformRequestOf);
775
777
  }
776
778
  async listPlatformRequestHistory(tenantId, scopeId, filter) {
@@ -905,12 +907,13 @@ export class CloudflareScopeHost {
905
907
  }
906
908
  /**
907
909
  * The platform's revoke on the CP-less path (#1686) — how a re-minted claim link retires the
908
- * previous one. True when the scope held the capability (revoked now, or already); false when
909
- * it holds no such capability. No lifecycle gate: a revoke only narrows, so a held scope still
910
- * takes one. Idempotent.
910
+ * previous one, and the far end of the operator's revoke (`CapabilityDelegation`, behind
911
+ * `/internal/capabilities/revoke`). The record as it stood before (revoked now, or already),
912
+ * for the control plane's admin log; null when the scope holds no such capability. No
913
+ * lifecycle gate: a revoke only narrows, so a held scope still takes one. Idempotent.
911
914
  */
912
915
  async revokeCapabilityLocal(scopeId, capabilityId, actor) {
913
- return (await this.scopeStub(scopeId).revokeCapabilityAsPlatform(capabilityId, actor)) !== null;
916
+ return this.scopeStub(scopeId).revokeCapabilityAsPlatform(capabilityId, actor);
914
917
  }
915
918
  /**
916
919
  * Copy one scope's data into a fresh scope DO, entirely within THIS deployment —
@@ -1268,9 +1271,11 @@ export class CloudflareScopeHost {
1268
1271
  }
1269
1272
  this.predicateNames.set(name, manifest.id);
1270
1273
  }
1274
+ // #1835: a forged `operations` is refused at construction too, before its scope ever wakes.
1275
+ assertBoundOperations(manifest.id, registration.operations);
1271
1276
  // #119: the trash rules, refused here too so a bad module fails at construction, not at its
1272
1277
  // scope's first wake. The scope holds the targets; the coordinator only needs the verdict.
1273
- registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
1278
+ registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations?.handlers ?? {})), registration.operations?.inputs, manifest.entityStates, manifest.schedules);
1274
1279
  // #1705: after the checks above, so a refused module leaves nothing registered here.
1275
1280
  this.crossVertical.register(manifest, registration.imports);
1276
1281
  this.moduleIds.add(manifest.id);
@@ -1284,7 +1289,7 @@ export class CloudflareScopeHost {
1284
1289
  this.moduleFreshness.set(manifest.id, manifest.freshness);
1285
1290
  }
1286
1291
  this.migrationTotal += migrations.length;
1287
- const ownOperations = new Set(Object.keys(registration.operations ?? {}));
1292
+ const ownOperations = new Set(Object.keys(registration.operations?.handlers ?? {}));
1288
1293
  for (const name of manifest.withdraws ?? []) {
1289
1294
  if (ownOperations.has(name)) {
1290
1295
  throw new Error(`${manifest.id} withdraws its own operation: ${name} (a module cannot withdraw itself — just don't register it)`);
@@ -1296,9 +1301,9 @@ export class CloudflareScopeHost {
1296
1301
  // #893: the facade validates what the DO will enforce. A schema declared for
1297
1302
  // an operation this module does not bind enforces nothing while reading as
1298
1303
  // coverage — refused here so it is caught at registration rather than never.
1299
- const unboundInputs = Object.keys(registration.operationInputs ?? {}).filter((name) => !ownOperations.has(name));
1304
+ const unboundInputs = Object.keys(registration.operations?.inputs ?? {}).filter((name) => !ownOperations.has(name));
1300
1305
  if (unboundInputs.length > 0) {
1301
- throw new Error(`${manifest.id} declares operationInputs for unbound operation(s): ` +
1306
+ throw new Error(`${manifest.id} declares operations.inputs for unbound operation(s): ` +
1302
1307
  `${unboundInputs.sort().join(', ')} — a schema on nothing reads as a parse that is not there`);
1303
1308
  }
1304
1309
  const binding = {
@@ -1306,7 +1311,7 @@ export class CloudflareScopeHost {
1306
1311
  entitlementKey: manifest.entitlementKey,
1307
1312
  scheduledOperations: new Set((manifest.schedules ?? []).map((sch) => sch.operation)),
1308
1313
  };
1309
- for (const name of Object.keys(registration.operations ?? {})) {
1314
+ for (const name of Object.keys(registration.operations?.handlers ?? {})) {
1310
1315
  this.bindOperation(name);
1311
1316
  this.operationEntitlement.set(name, binding);
1312
1317
  }
@@ -2218,6 +2223,38 @@ export class CloudflareScopeHost {
2218
2223
  const result = await this.scopeStub(scopeId).revokeScopeRolesBoundedFor(tenantId, scopeId, principalId.parse(caller), principalId.parse(principal));
2219
2224
  return { coverage: coverage.parse(result.coverage), revoked: result.revoked };
2220
2225
  }
2226
+ /**
2227
+ * A principal's `become` mint (#1686) — a member invite's link, minted by the member who
2228
+ * invites. The bound (`becomeMintCheck`: the caller holds everything the target holds here)
2229
+ * and the write are one ScopeDO task; a refusal is `{ ok: false, coverage }` and writes
2230
+ * nothing. Same gate as the scope-role verbs, which the invite grants with beside it.
2231
+ */
2232
+ async mintBecomeCapabilityBounded(tenantId, scopeId, caller, input) {
2233
+ await this.scopeRoleGate(tenantId, scopeId, 'mintBecomeCapabilityBounded');
2234
+ return boundedBecomeMint.parse(await this.scopeStub(scopeId).mintBecomeCapabilityBoundedFor(tenantId, scopeId, principalId.parse(caller), principalBecomeCapabilityInput.parse(input)));
2235
+ }
2236
+ /**
2237
+ * Revoke a `become` capability a principal minted (#1686) — what withdrawing a member invite
2238
+ * does to its link. `revoked` is false when it was already revoked or is not such a capability
2239
+ * (a platform-minted claim link, an `act` share). The kernel bounds `by` in the same ScopeDO
2240
+ * task: the link's minter, or someone holding everything its principal holds now.
2241
+ */
2242
+ async revokeBecomeCapability(tenantId, scopeId, capabilityId, by) {
2243
+ await this.scopeRoleGate(tenantId, scopeId, 'revokeBecomeCapability');
2244
+ return boundedBecomeRevoke.parse(await this.scopeStub(scopeId).revokeBecomeCapabilityFor(tenantId, scopeId, capabilityId, principalId.parse(by)));
2245
+ }
2246
+ /**
2247
+ * Where each named `become` link stands (#1686) — what a pending-invite list shows beside each
2248
+ * invite, so a link the kernel revoked or that expired is never shown as open.
2249
+ */
2250
+ async becomeLinkStates(tenantId, scopeId, ids) {
2251
+ // Refused here, on the near side, where the typed refusal reaches the caller as one.
2252
+ assertBecomeLinkStateIds(ids);
2253
+ await this.scopeRoleGate(tenantId, scopeId, 'becomeLinkStates');
2254
+ if (ids.length === 0)
2255
+ return [];
2256
+ return (await this.scopeStub(scopeId).becomeLinkStatesFor(tenantId, scopeId, [...ids])).map((state) => becomeLinkState.parse(state));
2257
+ }
2221
2258
  /** The (tenant, scope) gate the scope-role verbs share — `assignScopeRoleBounded`'s two checks. */
2222
2259
  async scopeRoleGate(tenantId, scopeId, verb) {
2223
2260
  if (this.cpLess && !(await this.scopeStub(scopeId).servesTenant(tenantId))) {
@@ -2315,26 +2352,34 @@ export class CloudflareScopeHost {
2315
2352
  this.systemSwitchDelegation ||
2316
2353
  this.peerSwitchDelegation ||
2317
2354
  this.eventDrainDelegation ||
2318
- this.importCursorDelegation);
2355
+ this.importCursorDelegation ||
2356
+ this.capabilityDelegation);
2319
2357
  }
2320
2358
  /**
2321
2359
  * Where a platform capability verb (#1672) may write: this host's own ScopeDO — refused,
2322
2360
  * loudly, on the shared control plane for a scope bound to a vertical, whose storage lives
2323
2361
  * in that vertical's deployment. Writing through `this.scopeStub` there would mint into
2324
- * an empty DO of the wrong namespace, a capability nobody could ever exchange. Delegating
2325
- * these verbs to the serving deployment (as the schedule switch is) is a follow-up; the
2326
- * claim-link migration is the first caller that needs it.
2362
+ * an empty DO of the wrong namespace, a capability nobody could ever exchange. The revoke
2363
+ * reaches a hosted scope through `capabilityDelegation` instead and asks here only when none is
2364
+ * configured; the mint has no such reach and no caller needing one, since an owner claim link
2365
+ * is minted by the deployment itself behind `/internal/owner-claim`.
2327
2366
  */
2328
- async capabilityScopeStub(tenantId, scopeId, verb) {
2329
- const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2330
- if (!rec)
2331
- throw unknownScopeForTenant(tenantId, scopeId);
2367
+ async capabilityScopeStub(rec, scopeId, verb) {
2332
2368
  if (this.servesScopesElsewhereNow && rec.vertical !== null) {
2333
2369
  throw substratError('unavailable', `${verb} cannot reach scope ${scopeId}: it is served by the '${rec.vertical}' ` +
2334
- 'deployment, and platform capability verbs are not delegated there yet');
2370
+ 'deployment, and this host has no reach into it');
2335
2371
  }
2336
2372
  await this.migrateAndRecord(scopeId);
2337
- return { stub: this.scopeStub(scopeId), vertical: rec.vertical };
2373
+ return this.scopeStub(scopeId);
2374
+ }
2375
+ /**
2376
+ * The directory's record of a scope a platform capability verb addresses: `not_found`, or
2377
+ * `conflict` for a REAPED scope (`scopeRecordForRead`'s refusal). Its row is a tombstone and its
2378
+ * storage is gone, so addressing its DO — here or in the deployment — would construct an empty
2379
+ * one. An archived scope still answers: its bytes, and the capabilities in them, are there.
2380
+ */
2381
+ async capabilityScopeRecord(tenantId, scopeId) {
2382
+ return this.scopeRecordForRead(tenantId, scopeId);
2338
2383
  }
2339
2384
  /** #1705: what this deployment imports — the sweep's reason to call no scope when it is empty. */
2340
2385
  registeredImports() {
@@ -2862,6 +2907,7 @@ export class CloudflareScopeHost {
2862
2907
  expiresAt: r.expires_at,
2863
2908
  ...(r.serving_ref ? { servingRef: r.serving_ref } : {}),
2864
2909
  archivedAt: r.archived_at ?? null,
2910
+ archivedFromStatus: (r.archived_from_status ?? null),
2865
2911
  createdAt: r.created_at,
2866
2912
  });
2867
2913
  // The (version, scope) pair a bind and its impact read both start from, and the refusals
@@ -3508,11 +3554,11 @@ export class CloudflareScopeHost {
3508
3554
  await this.recordAdmin(actor, 'grantEntityShape', grant.node, null, grant);
3509
3555
  },
3510
3556
  reconcileEntityGrantShapes: async (actor, node, shapes, opts) => {
3511
- const { toppedUp, retired } = await this.topUpEntityGrantShapesLocal(node.tenantId, node.scopeId, shapes, opts?.batch);
3512
- if (toppedUp > 0 || retired > 0) {
3513
- await this.recordAdmin(actor, 'reconcileEntityGrantShapes', node, null, { shapes, toppedUp, retired });
3557
+ const result = await this.topUpEntityGrantShapesLocal(node.tenantId, node.scopeId, shapes, opts?.batch);
3558
+ if (result.toppedUp > 0 || result.retired > 0) {
3559
+ await this.recordAdmin(actor, 'reconcileEntityGrantShapes', node, null, { shapes, ...result });
3514
3560
  }
3515
- return { toppedUp, retired };
3561
+ return result;
3516
3562
  },
3517
3563
  grantToConnection: async (actor, raw) => {
3518
3564
  const grant = connectionGrant.parse(raw);
@@ -3644,9 +3690,10 @@ export class CloudflareScopeHost {
3644
3690
  // two sides cannot disagree about what a valid mint is.
3645
3691
  checkBecomeInput(input, new Date().toISOString());
3646
3692
  await this.validateScopeAccess(tenantId, scopeId);
3647
- const { stub, vertical } = await this.capabilityScopeStub(tenantId, scopeId, 'mintCapability');
3693
+ const rec = await this.capabilityScopeRecord(tenantId, scopeId);
3694
+ const stub = await this.capabilityScopeStub(rec, scopeId, 'mintCapability');
3648
3695
  const minted = await stub.mintBecomeCapability(input, actor);
3649
- await this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId, vertical }, null, {
3696
+ await this.recordAdmin(actor, 'mintCapability', { tenantId, scopeId, vertical: rec.vertical }, null, {
3650
3697
  capabilityId: minted.id,
3651
3698
  mode: 'become',
3652
3699
  principal: input.principal,
@@ -3657,14 +3704,29 @@ export class CloudflareScopeHost {
3657
3704
  return minted;
3658
3705
  },
3659
3706
  revokeCapability: async (actor, tenantId, scopeId, capabilityId) => {
3660
- const { stub, vertical } = await this.capabilityScopeStub(tenantId, scopeId, 'revokeCapability');
3661
- const before = await stub.revokeCapabilityAsPlatform(capabilityId, actor);
3662
- if (!before) {
3663
- throw substratError('not_found', `no capability ${capabilityId} in scope ${scopeId}`);
3707
+ // #1686: a hosted scope's directory is in the deployment serving it, reached through the
3708
+ // delegation; this host's own ScopeDO otherwise. Where it lands is settled before the
3709
+ // audit opens: a scope this host cannot reach is refused with nothing written.
3710
+ const rec = await this.capabilityScopeRecord(tenantId, scopeId);
3711
+ const { vertical } = rec;
3712
+ const delegation = this.capabilityDelegation;
3713
+ let revoke;
3714
+ if (vertical !== null && delegation) {
3715
+ const served = { vertical, verticalVersionId: rec.vertical_version_id, servingRef: rec.serving_ref };
3716
+ revoke = () => delegation.revoke({ tenantId, scopeId, served, capabilityId, actor });
3717
+ }
3718
+ else {
3719
+ const stub = await this.capabilityScopeStub(rec, scopeId, 'revokeCapability');
3720
+ revoke = () => stub.revokeCapabilityAsPlatform(capabilityId, actor);
3664
3721
  }
3665
- await this.recordAdmin(actor, 'revokeCapability', { tenantId, scopeId, vertical }, before, {
3722
+ // Intent first, then the outcome (#1666's grammar): a revoke whose answer is lost, or
3723
+ // whose outcome row cannot be written, is still on the admin log, and settles `unknown`.
3724
+ await auditedCapabilityRevoke({
3666
3725
  capabilityId,
3667
- revoked: true,
3726
+ scopeId,
3727
+ record: (before, after) => this.recordAdmin(actor, 'revokeCapability', { tenantId, scopeId, vertical }, before, after),
3728
+ revoke,
3729
+ logError: (message, fields) => console.error(message, fields),
3668
3730
  });
3669
3731
  },
3670
3732
  grantToOrg: async (actor, orgId, permission, node, entity) => {
@@ -4165,6 +4227,12 @@ export class CloudflareScopeHost {
4165
4227
  if (result === 'pending')
4166
4228
  throw substratError('precondition_failed', `scope ${scopeId} has a copy move in flight; retry reap after it settles`);
4167
4229
  },
4230
+ backfillScopeScriptCopy: async (actor, tenantId, scopeId, scriptRef, opts) => {
4231
+ if (!scriptRef)
4232
+ throw substratError('conflict', 'a backfilled copy must name a real script');
4233
+ // The audit row is written in the insert's own unit, stamped there with its erasure epoch.
4234
+ return this.cp.backfillScopeScriptCopy(tenantId, scopeId, scriptRef, this.adminEntry(actor, 'backfillScopeCopy', { tenantId, scopeId }, null, { scriptRef, fromLogId: opts?.fromLogId ?? null }));
4235
+ },
4168
4236
  claimExpiredScopeScriptCopies: async (_actor, input) => (await this.cp.claimExpiredScopeScriptCopies(input)).map(scopeScriptCopyOf),
4169
4237
  settleScopeScriptCopy: async (_actor, tenantId, scopeId, scriptRef, moveId, state, marker, opts) => this.cp.settleScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, state, marker?.loadStamp ?? null, marker?.revision ?? null, opts?.claimedBy),
4170
4238
  touchScopeScriptCopy: async (_actor, tenantId, scopeId, scriptRef, moveId) => this.cp.touchScopeScriptCopy(tenantId, scopeId, scriptRef, moveId),
@@ -4836,6 +4904,9 @@ export class CloudflareScopeHost {
4836
4904
  },
4837
4905
  shredSubject: async (actor, tenantId, scopeId, subjectId) => {
4838
4906
  await this.assertScope(tenantId, scopeId);
4907
+ // #1722: a direct shred claims nothing; it records the epoch it ran under, as an orchestrated
4908
+ // one records the epoch its claim compared, so the backfill can order every erasure.
4909
+ const stamp = { erasureEpoch: await this.cp.scopeErasureEpoch(tenantId, scopeId), path: 'direct' };
4839
4910
  // Redact the live spine FIRST, destroy the key LAST. Both halves are idempotent and
4840
4911
  // a crash between them converges on retry, so the order is decided by which
4841
4912
  // half-done state harms the person: dying after the redaction leaves ciphertext in
@@ -4909,7 +4980,7 @@ export class CloudflareScopeHost {
4909
4980
  // BOTH logs, deliberately: the admin log because this is a mutation, the access log
4910
4981
  // because it destroys evidence. An erasure is the one action where "who asked for
4911
4982
  // this to disappear" is itself part of the record.
4912
- await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
4983
+ await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, stamp, receipt);
4913
4984
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
4914
4985
  // an intent payload is a whole event's worth of it.
4915
4986
  await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults + moduleRowsErased(vertical));
@@ -4937,7 +5008,8 @@ export class CloudflareScopeHost {
4937
5008
  keyDestroyed: existed,
4938
5009
  tombstoned: true,
4939
5010
  });
4940
- await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
5011
+ // The epoch the claim compared (#1722): the backfill orders itself against this erasure by it.
5012
+ await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, { erasureEpoch: expected.epoch, path: 'orchestrated' }, receipt);
4941
5013
  await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, redactions.reduce((n, r) => n + r.events + r.intents + r.jobRuns + r.idempotencyResults + moduleRowsErased(r.vertical), 0));
4942
5014
  return receipt;
4943
5015
  },
@@ -5036,6 +5108,7 @@ export class CloudflareScopeHost {
5036
5108
  scopes: rows.scopes.map((r) => ({
5037
5109
  tenantId: r.tenant_id,
5038
5110
  status: r.status,
5111
+ archivedFromStatus: r.archived_from_status,
5039
5112
  })),
5040
5113
  entitlements: rows.entitlements.map((r) => ({
5041
5114
  tenantId: r.tenant_id,
@@ -5043,6 +5116,8 @@ export class CloudflareScopeHost {
5043
5116
  plan: r.plan,
5044
5117
  expiresAt: r.expires_at,
5045
5118
  })),
5119
+ storage: rows.storage,
5120
+ storageAttempts: rows.storageAttempts,
5046
5121
  });
5047
5122
  // Tenants covered, not totals: "read one tenant's meter" and "metered the whole
5048
5123
  // fleet" are different acts, and K-24 exists to tell them apart.
@@ -5140,7 +5215,7 @@ export class CloudflareScopeHost {
5140
5215
  createdBy: input.createdBy ?? actor,
5141
5216
  });
5142
5217
  },
5143
- connectionSealingKey: (id) => this.ensureSealingKey(id),
5218
+ connectionSealingKey: async (id) => this.ensureSealingKey(id),
5144
5219
  connectionSealingKeys: async (tenantId, vertical) => {
5145
5220
  // LIVE connections only. A revoked connection's key is KEPT (its pending
5146
5221
  // ciphertext must still open) but stops being projected, so a scope can no
@@ -5363,7 +5438,9 @@ export class CloudflareScopeHost {
5363
5438
  return rows;
5364
5439
  },
5365
5440
  /** #2064: settle an intent with no outcome — one unit in the directory DO. */
5366
- settleUnrecordedOutcome: (actor, input) => this.cp.settleUnrecordedOutcome({ actor, intentId: input.intentId, error: input.error }),
5441
+ // `async`, like every other method here: a bare stub call hands the caller workerd's RPC
5442
+ // promise, which is callable (for pipelining) where `Promise<boolean>` is not (#2131).
5443
+ settleUnrecordedOutcome: async (actor, input) => this.cp.settleUnrecordedOutcome({ actor, intentId: input.intentId, error: input.error }),
5367
5444
  /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
5368
5445
  recordCopyMark: async (actor, entry) => {
5369
5446
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
@@ -5570,6 +5647,18 @@ export class CloudflareScopeHost {
5570
5647
  await this.recordAccess(actor, 'listFindingRules', { tenantId }, filter, rows.length);
5571
5648
  return rows.map((r) => findingRuleEntry.parse(r));
5572
5649
  },
5650
+ recordScopeStorage: async (_actor, readings) => this.cp.recordScopeStorage(readings),
5651
+ listScopeStorage: async (actor, filter) => {
5652
+ const rows = (await this.cp.listScopeStorage(filter)).map((r) => scopeStorageSample.parse(r));
5653
+ await this.recordAccess(actor, 'listScopeStorage', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter ?? null, rows.length);
5654
+ return rows;
5655
+ },
5656
+ listScopeStorageAttempts: async (actor) => {
5657
+ const rows = await this.cp.listScopeStorageAttempts();
5658
+ await this.recordAccess(actor, 'listScopeStorageAttempts', { tenantId: null }, null, rows.length);
5659
+ return rows;
5660
+ },
5661
+ pruneScopeStorage: async (_actor, limit) => this.cp.pruneScopeStorage(assertRowLimit('limit', limit)),
5573
5662
  recordModelUsage: async (input) => {
5574
5663
  const l = input.line;
5575
5664
  return this.cp.recordModelUsage({
@@ -6790,22 +6879,29 @@ export class CloudflareScopeHost {
6790
6879
  * passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
6791
6880
  * anything else is `validation_failed`), repeated until a pass finishes. Never re-grants a revoked
6792
6881
  * key; takes back only a key the shape declares `retired` (#2082). Returns how many
6793
- * (person, entity) it topped up, and how many it took retired keys from.
6882
+ * (person, entity) it topped up, how many it took retired keys from, and (only when nonzero) how
6883
+ * many shapes' retirements it left open for the next reconcile.
6794
6884
  */
6795
6885
  async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
6796
6886
  const limit = shapeTopUpBatch(batch);
6797
6887
  let toppedUp = 0;
6798
6888
  let retired = 0;
6889
+ let retirementsLeftOpen = 0;
6799
6890
  if (shapes.length === 0)
6800
6891
  return { toppedUp, retired };
6801
6892
  const stub = this.scopeStub(scopeId);
6802
- for (let done = false; !done;) {
6803
- const pass = await stub.topUpEntityGrantShapes(tenantId, scopeId, shapes, limit);
6893
+ // Each pass resumes where the last one stopped (#2083), until one reports nothing left. A DO
6894
+ // from before the cursor answers no `next`: its passes each start over, until it says `done`.
6895
+ let after = null;
6896
+ for (let more = true; more;) {
6897
+ const pass = await stub.topUpEntityGrantShapes(tenantId, scopeId, shapes, limit, after);
6804
6898
  toppedUp += pass.toppedUp;
6805
6899
  retired += pass.retired;
6806
- done = pass.done;
6900
+ retirementsLeftOpen += pass.retirementsLeftOpen ?? 0; // a DO from before the cap reports none
6901
+ after = pass.next ?? null;
6902
+ more = pass.next === undefined ? !pass.done : pass.next !== null;
6807
6903
  }
6808
- return { toppedUp, retired };
6904
+ return { toppedUp, retired, ...(retirementsLeftOpen > 0 ? { retirementsLeftOpen } : {}) };
6809
6905
  }
6810
6906
  // -- the connector write-back's far end (#574) -----------------------------
6811
6907
  // A CP-less dispatch vertical cannot run a connector, so the shared control