@substrat-run/adapter-cloudflare 0.139.0 → 0.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { env as ambientEnv } from 'cloudflare:workers';
2
2
  import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
3
3
  import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
4
- import { delegatedReadParams, operationSeriesCount, fromWireFailure, 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, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, copyMarkAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, 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, 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';
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, 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, memberAddedAudit, } from '@substrat-run/kernel';
7
- import { attributedView } from '@substrat-run/kernel';
8
- import { isOrangeToOrange, isUpgradeRequest, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, encodeLiveWithin, liveWithinOf, } from './live-reads.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';
7
+ import { COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
8
+ import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased } from '@substrat-run/kernel';
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';
9
10
  import { tenantStoreDatabaseName } from './d1.js';
10
11
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
11
12
  import { unwrapReply } from './do-reply.js';
@@ -522,13 +523,12 @@ export class CloudflareScopeHost {
522
523
  // have none, and the directory is one global object.
523
524
  let inert;
524
525
  const isInert = () => (inert ??= this.isInertScope(tenantId, scopeId));
525
- // #1713: a CP-less scope its lifecycle holds attempts nothing and journals nothing, so every
526
- // due delivery stays due for the first pass after it is live again. Asked on the first due
527
- // event, like `isInert`. Every caller passed `assertLive` already, so this is for the scope
528
- // suspended between that gate and the drain: a request in flight, whose write commits while
529
- // its effect waits. A host with a directory answers false without a read.
530
- let held;
531
- const isHeld = () => (held ??= this.lifecycleHeld(scopeId));
526
+ // A suspension can land after `assertLive`, or between two deliveries. A live
527
+ // verdict is never cached across this pass: each pending event meets the current
528
+ // directory or, on a CP-less host, the scope's delivered lifecycle.
529
+ const isHeld = async () => this.cpLess
530
+ ? this.lifecycleHeld(scopeId)
531
+ : (await this.cp.scopeAccessRefusal(tenantId, scopeId)) !== null;
532
532
  drain: for (const [id, executor] of this.executors) {
533
533
  const deliveryId = `executor:${id}`;
534
534
  const { events, undecodable } = await stub.pendingExecutorDeliveries(deliveryId, executor.eventType);
@@ -536,10 +536,6 @@ export class CloudflareScopeHost {
536
536
  // and its handler never sees it. Terminal on the FIRST failure, unlike a handler's:
537
537
  // the decode is pure, so a retry cannot succeed. The rows behind it are delivered
538
538
  // below — the decode used to throw the whole list, on every pass.
539
- if ((undecodable.length > 0 || events.length > 0) && (await isHeld())) {
540
- report.lifecycleHeld = true;
541
- break drain;
542
- }
543
539
  // #1901: an attempt's line. The attempt number is the one the journal is about to record.
544
540
  const unitOf = (eventId, attempt) => ({
545
541
  kind: 'consumer',
@@ -556,12 +552,20 @@ export class CloudflareScopeHost {
556
552
  outcomes?.push(executorOutcomeOf(id, event, outcome, error));
557
553
  };
558
554
  for (const bad of undecodable) {
555
+ if (await isHeld()) {
556
+ report.lifecycleHeld = true;
557
+ break drain;
558
+ }
559
559
  report.attempted += 1;
560
560
  const attempt = await stub.recordExecutorAttempt(bad.eventId, deliveryId, bad.error, null, invocationId);
561
561
  report.deadLettered += 1;
562
562
  lines.write({ ...unitOf(bad.eventId, attempt), outcome: 'dead-lettered' });
563
563
  }
564
564
  for (const event of events) {
565
+ if (await isHeld()) {
566
+ report.lifecycleHeld = true;
567
+ break drain;
568
+ }
565
569
  report.attempted += 1;
566
570
  const startedAt = Date.now();
567
571
  if (await isInert()) {
@@ -777,6 +781,12 @@ export class CloudflareScopeHost {
777
781
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
778
782
  await this.validateScopeAccess(tenantId, scopeId);
779
783
  await this.migrateAndRecord(scopeId);
784
+ // #2102: an outcome event travels by the verb that writes it; every other settle keeps the
785
+ // positional one, so it still reaches a DO class that predates the event.
786
+ if (outcome.event) {
787
+ await this.scopeStub(scopeId).settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome);
788
+ return;
789
+ }
780
790
  await this.scopeStub(scopeId).settlePlatformRequest(id, outcome.status, outcome.result === undefined ? null : JSON.stringify(outcome.result), outcome.lastError ?? null, outcome.failure == null ? null : JSON.stringify(outcome.failure));
781
791
  }
782
792
  migrationFrontier() {
@@ -964,6 +974,7 @@ export class CloudflareScopeHost {
964
974
  const out = await this.scopeStub(scopeId).importDumpChecked(tables, scopeId, {
965
975
  ...load,
966
976
  ...(opts?.expect ? { expect: opts.expect } : {}),
977
+ ...(opts?.fence ? { fence: opts.fence } : {}),
967
978
  });
968
979
  if (out.refused === false) {
969
980
  return { tables: tables.length, ...(carry ? { switchedOff: out.switchedOff } : {}) };
@@ -972,6 +983,8 @@ export class CloudflareScopeHost {
972
983
  throw substratError('conflict', KEPT_COPY_REFUSAL);
973
984
  if (out.refused === 'tenant')
974
985
  throw substratError('conflict', out.message);
986
+ if (out.refused === 'lapsed')
987
+ throw substratError('precondition_failed', COPY_RESTORE_FENCE_LAPSED);
975
988
  // A retry of a load that already committed (its answer was lost on the way back) is
976
989
  // refused by the marker that load itself moved. The store holding THIS request's stamp
977
990
  // says so: no other load writes it. Answered as applied, without `switchedOff`, so the
@@ -1058,6 +1071,17 @@ export class CloudflareScopeHost {
1058
1071
  assertCopyLineage(lineage);
1059
1072
  return { marked: await this.scopeStub(scopeId).markCopy() };
1060
1073
  }
1074
+ /** Scope-side half of an erasure, reached through this script's own DO binding. */
1075
+ async redactSubjectLocal(scopeId, subjectId) {
1076
+ const answer = await this.scopeStub(scopeId).redactSubject(subjectId);
1077
+ if (typeof answer === 'object' && 'failure' in answer && answer.failure)
1078
+ throw fromWireFailure(answer.failure);
1079
+ if (typeof answer === 'number' || !('jobRuns' in answer) || !('idempotencyResults' in answer) ||
1080
+ !Array.isArray(answer.intentIds) || !('vertical' in answer) || !isModuleErasureCounts(answer.vertical)) {
1081
+ throw substratError('unavailable', `scope ${scopeId} runs a DO whose subject erasure is incomplete — redeploy and retry`);
1082
+ }
1083
+ return answer;
1084
+ }
1061
1085
  /**
1062
1086
  * Clear a mistaken copy classification from one scope in THIS deployment (#2005), behind the
1063
1087
  * vertical's `/internal/clear-copy-mark`: staff's correction for a scope the directory says IS
@@ -1219,6 +1243,9 @@ export class CloudflareScopeHost {
1219
1243
  if (this.moduleIds.has(manifest.id)) {
1220
1244
  throw new Error(`module already registered: ${manifest.id}`);
1221
1245
  }
1246
+ // #2068: the same refusal the ScopeDO applies at code time, here at registration, so a
1247
+ // misdeclared erasure fails where it is registered on both hosts.
1248
+ moduleErasurePlan(registration);
1222
1249
  const migrations = registration.migrations ?? [];
1223
1250
  const seen = new Set();
1224
1251
  for (const m of migrations) {
@@ -1241,6 +1268,9 @@ export class CloudflareScopeHost {
1241
1268
  }
1242
1269
  this.predicateNames.set(name, manifest.id);
1243
1270
  }
1271
+ // #119: the trash rules, refused here too so a bad module fails at construction, not at its
1272
+ // 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);
1244
1274
  // #1705: after the checks above, so a refused module leaves nothing registered here.
1245
1275
  this.crossVertical.register(manifest, registration.imports);
1246
1276
  this.moduleIds.add(manifest.id);
@@ -2340,8 +2370,7 @@ export class CloudflareScopeHost {
2340
2370
  if (!this.moduleIds.has(moduleId))
2341
2371
  return report;
2342
2372
  if (!this.cpLess) {
2343
- const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2344
- if (!rec || rec.status !== 'active')
2373
+ if (await this.cp.scopeAccessRefusal(tenantId, scopeId))
2345
2374
  return report;
2346
2375
  }
2347
2376
  else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
@@ -2392,8 +2421,7 @@ export class CloudflareScopeHost {
2392
2421
  // the same call that holds the pair to the scope's own record (#2016): a roster entry whose
2393
2422
  // tenant disagrees is refused before a grant, a cadence row or a run is read or written.
2394
2423
  if (!this.cpLess) {
2395
- const rec = await this.cp.getScopeRecord(tenantId, scopeId);
2396
- if (!rec || rec.status !== 'active')
2424
+ if (await this.cp.scopeAccessRefusal(tenantId, scopeId))
2397
2425
  return report;
2398
2426
  }
2399
2427
  else if (lifecycleRefusal(await this.validateScopeAccess(tenantId, scopeId)) !== null) {
@@ -2450,22 +2478,53 @@ export class CloudflareScopeHost {
2450
2478
  const startedAt = Date.now();
2451
2479
  let emitted;
2452
2480
  let failure;
2481
+ // #119, #2096: more of a purge horizon's lap is due — the schedule stays due, so the next pass continues it.
2482
+ let stillDue = false;
2453
2483
  try {
2454
2484
  // The gate above already answered for this pass; a fire that meets a restarted scope
2455
- // is gated again by the door (#1834).
2485
+ // is gated again by the door (#1834). The door is opened once, but the lifecycle is
2486
+ // read again before every later fire: the scope does not hold a directory lifecycle,
2487
+ // so a suspension after an earlier fire would otherwise not stop this one (#1713).
2488
+ if (door)
2489
+ await this.assertLive(tenantId, scopeId);
2456
2490
  door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
2457
2491
  const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door);
2458
- await scope.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
2492
+ if (schedule.purge) {
2493
+ // #119: a purge horizon's schedule runs its operation once per due entity, each its own
2494
+ // call and transaction — and the SCOPE decides which are due and runs them, so purge
2495
+ // authority is never an argument any stub can pass (`runPurgeSweep`).
2496
+ const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance));
2497
+ // The purges' events, delivered the way each invoke's own tail delivers them.
2498
+ await this.drainExecutors(tenantId, scopeId, null);
2499
+ stillDue = purgeStillDue(pass);
2500
+ const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
2501
+ report.errors.push(...outcome.errors);
2502
+ if (outcome.failure)
2503
+ throw outcome.failure;
2504
+ }
2505
+ else {
2506
+ await scope.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
2507
+ }
2459
2508
  report.fired += 1;
2460
2509
  }
2461
2510
  catch (err) {
2511
+ // A lifecycle delivery can land after the first gate but before the
2512
+ // system door. That refusal ran nothing and must not advance cadence.
2513
+ if (isSubstratError(err) && err.code === 'conflict' && err.extensions.reason === SCOPE_GATE_REASONS.notActive) {
2514
+ report.lifecycleHeld = true;
2515
+ report.skipped += 1;
2516
+ report.runs.push({ operation: schedule.operation, outcome: 'skipped' });
2517
+ continue;
2518
+ }
2462
2519
  status = 'failed';
2463
2520
  failure = { error: err };
2464
2521
  report.failed += 1;
2465
- report.errors.push({
2466
- operation: schedule.operation,
2467
- error: err instanceof Error ? err.message : String(err),
2468
- });
2522
+ if (!schedule.purge) {
2523
+ report.errors.push({
2524
+ operation: schedule.operation,
2525
+ error: err instanceof Error ? err.message : String(err),
2526
+ });
2527
+ }
2469
2528
  }
2470
2529
  lines.write({
2471
2530
  kind: 'schedule',
@@ -2480,10 +2539,12 @@ export class CloudflareScopeHost {
2480
2539
  latenessMs: lastRun === null ? null : now - dueAt,
2481
2540
  ...(emitted ? { emitted } : {}),
2482
2541
  });
2542
+ report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
2543
+ if (stillDue)
2544
+ continue;
2483
2545
  // #1288: 'schedule', whatever this operation happens to be called — including
2484
2546
  // `freshness:<something>`, which is exactly the row the evaluator no longer eats.
2485
2547
  await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule', invocationId);
2486
- report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
2487
2548
  }
2488
2549
  lines.end();
2489
2550
  return report;
@@ -2548,6 +2609,9 @@ export class CloudflareScopeHost {
2548
2609
  ? 'vertical'
2549
2610
  : undefined,
2550
2611
  invoke: async (operation, input, invokeOptions) => {
2612
+ // #119: purge authority is never an invoke option — only the scope's own `runPurgeSweep`
2613
+ // purges — and an options object naming a cutoff is refused outright.
2614
+ assertNoCallerPurge(invokeOptions);
2551
2615
  // Entitlement gate (§4.3): a module loads for a tenant only if the tenant holds its
2552
2616
  // SKU flag. The COORDINATOR gates the console-managed path against the shared CP
2553
2617
  // (`cp.tenantHoldsEntitlement`); for a hosted/CP-less scope that call is a trusting
@@ -2950,11 +3014,21 @@ export class CloudflareScopeHost {
2950
3014
  // admin log are separate, so no order makes the pair atomic; this one fails toward
2951
3015
  // "an intent with no recorded outcome" and never toward "a switch that moved with no
2952
3016
  // audit row". A retry after a crash re-audits even though it answers `changed: false`.
3017
+ //
3018
+ // #2089: every outcome row goes through the kernel's `recordAuditOutcome`, as the control
3019
+ // plane's audited changes do. One that cannot be written is logged with the operation id,
3020
+ // never swallowed; the call still answers with its own error (or, applied, with success and
3021
+ // `auditWarning`), and the scheduled settle closes the intent as `unknown`.
2953
3022
  const operationId = ulid();
2954
3023
  const target = { tenantId, scopeId, vertical };
2955
3024
  const base = { operationId, ...switchAuditSubject(kind, key, to) };
2956
3025
  await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
2957
3026
  const errorOf = (err) => (err instanceof Error ? err.message : String(err));
3027
+ const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, action, target, null, { ...base, ...row }), {
3028
+ flow: `${kind}-switch`,
3029
+ operationId,
3030
+ phase: row.phase,
3031
+ }, (message, fields) => console.error(message, fields));
2958
3032
  // #2045 (Codex r3): the deployment's fence is attested BEFORE anything is recorded or moved. An
2959
3033
  // ON on a deployment built before it is refused here, with nothing written anywhere but the
2960
3034
  // audit. An OFF goes on: the kill switch must work on every deployment (see `UnattestedSwitch`).
@@ -2964,7 +3038,7 @@ export class CloudflareScopeHost {
2964
3038
  }
2965
3039
  }
2966
3040
  catch (err) {
2967
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', error: errorOf(err) }).catch(() => undefined);
3041
+ await recordOutcome({ phase: 'refused', error: errorOf(err) });
2968
3042
  throw err;
2969
3043
  }
2970
3044
  // The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
@@ -2982,13 +3056,13 @@ export class CloudflareScopeHost {
2982
3056
  // Nothing has moved: fail the call here, audited. An ON must not switch a scope on
2983
3057
  // whose record still says off (the next reconcile would switch it back off), and an
2984
3058
  // OFF must not leave a scope off that the record does not know of (#1823).
2985
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) }).catch(() => undefined);
3059
+ await recordOutcome({ phase: 'failed', error: errorOf(err) });
2986
3060
  throw err;
2987
3061
  }
2988
3062
  // #2045: a newer call on this subject has recorded its position already: this one writes
2989
3063
  // nothing, here or in the scope, and says so.
2990
3064
  if (recordWriteSuperseded(prior, record)) {
2991
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
3065
+ await recordOutcome({ phase: 'refused', superseded: true });
2992
3066
  throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
2993
3067
  }
2994
3068
  let outcome;
@@ -3010,13 +3084,12 @@ export class CloudflareScopeHost {
3010
3084
  // readback can settle the scope and nothing is put back: the record is this call's position
3011
3085
  // under the newest fence, and its write-ahead mark makes the next re-assert move the scope
3012
3086
  // to it, under that fence, whichever way it lies now.
3013
- await this.recordAdmin(actor, action, target, null, {
3014
- ...base,
3087
+ await recordOutcome({
3015
3088
  phase: err instanceof UnattestedSwitch ? 'refused' : 'failed',
3016
3089
  error: errorOf(err),
3017
3090
  recordKept: true,
3018
3091
  reassertOwed: true,
3019
- }).catch(() => undefined);
3092
+ });
3020
3093
  if (err instanceof UnattestedSwitch)
3021
3094
  throw substratError('precondition_failed', err.message);
3022
3095
  throw err;
@@ -3025,7 +3098,7 @@ export class CloudflareScopeHost {
3025
3098
  // record is that newer call's too (its write overwrote this one's, mark included), so nothing
3026
3099
  // is undone and no mark is this call's to clear.
3027
3100
  if (outcome.superseded) {
3028
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
3101
+ await recordOutcome({ phase: 'refused', superseded: true });
3029
3102
  throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
3030
3103
  }
3031
3104
  // A call that held nothing moved nothing, so its record write is undone: left `off`, it
@@ -3052,8 +3125,7 @@ export class CloudflareScopeHost {
3052
3125
  else if (!unfenced) {
3053
3126
  owedError = await this.cp.clearSwitchOwed(kind, tenantId, scopeId, key, operationId).then(() => null, errorOf);
3054
3127
  }
3055
- await this.recordAdmin(actor, action, target, null, {
3056
- ...base,
3128
+ const unrecorded = await recordOutcome({
3057
3129
  phase: outcome.held ? 'applied' : 'refused',
3058
3130
  changed: outcome.changed,
3059
3131
  permissions: outcome.permissions,
@@ -3065,30 +3137,32 @@ export class CloudflareScopeHost {
3065
3137
  throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
3066
3138
  (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
3067
3139
  }
3068
- return { operationId, outcome };
3140
+ return { operationId, outcome, ...(unrecorded !== null ? { auditWarning: auditWarningOf('the switch', unrecorded) } : {}) };
3069
3141
  };
3070
3142
  /** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
3071
3143
  const switchSystem = async (actor, raw, to) => {
3072
3144
  const input = systemSwitch.parse(raw);
3073
- const { operationId, outcome } = await switchSubjectAt(actor, 'system', input.node, input.moduleId, input.reason, to);
3145
+ const { operationId, outcome, auditWarning } = await switchSubjectAt(actor, 'system', input.node, input.moduleId, input.reason, to);
3074
3146
  return {
3075
3147
  operationId,
3076
3148
  moduleId: input.moduleId,
3077
3149
  schedules: to,
3078
3150
  changed: outcome.changed,
3079
3151
  permissions: outcome.permissions,
3152
+ ...(auditWarning ? { auditWarning } : {}),
3080
3153
  };
3081
3154
  };
3082
3155
  /** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
3083
3156
  const switchPeerAt = async (actor, raw, to) => {
3084
3157
  const input = peerSwitch.parse(raw);
3085
- const { operationId, outcome } = await switchSubjectAt(actor, 'peer', input.node, input.vertical, input.reason, to);
3158
+ const { operationId, outcome, auditWarning } = await switchSubjectAt(actor, 'peer', input.node, input.vertical, input.reason, to);
3086
3159
  return {
3087
3160
  operationId,
3088
3161
  vertical: input.vertical,
3089
3162
  calls: to,
3090
3163
  changed: outcome.changed,
3091
3164
  permissions: outcome.permissions,
3165
+ ...(auditWarning ? { auditWarning } : {}),
3092
3166
  };
3093
3167
  };
3094
3168
  /**
@@ -3355,6 +3429,8 @@ export class CloudflareScopeHost {
3355
3429
  return result;
3356
3430
  };
3357
3431
  return {
3432
+ // #2069: a view of the view this admin was built over — the person added, its event kept.
3433
+ attributed: (onBehalfOf) => this.attributed(onBehalfOf).admin,
3358
3434
  // #603: fixed at construction — a worker deployed without SECRET_BOX_KEY can never
3359
3435
  // store a credential, and saying so is what lets a transport answer 503 instead of 500.
3360
3436
  canStoreSecrets: isSecretBoxConfigured(this.secretBox),
@@ -3427,6 +3503,17 @@ export class CloudflareScopeHost {
3427
3503
  if (!grant.node.scopeId)
3428
3504
  await this.fanOut(grant.node.tenantId);
3429
3505
  },
3506
+ grantEntityShape: async (actor, grant) => {
3507
+ await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
3508
+ await this.recordAdmin(actor, 'grantEntityShape', grant.node, null, grant);
3509
+ },
3510
+ 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 });
3514
+ }
3515
+ return { toppedUp, retired };
3516
+ },
3430
3517
  grantToConnection: async (actor, raw) => {
3431
3518
  const grant = connectionGrant.parse(raw);
3432
3519
  const conn = await this.cp.readConnection(grant.connectionId);
@@ -3683,6 +3770,23 @@ export class CloudflareScopeHost {
3683
3770
  // #1706: the directory's answer to "vertical Y in tenant T", in the ControlPlaneDO, by
3684
3771
  // the kernel's one rule. No actor, not logged — `resolveHostname`'s machine-path reason.
3685
3772
  resolveVerticalInstance: async (tenantId, vertical) => verticalResolution.parse(await this.cp.resolveVerticalInstance(tenantId, verticalSlug.parse(vertical))),
3773
+ resolvePeerInstance: async (tenantId, callerScopeId, vertical) => peerInstanceResolution.parse(await this.cp.resolvePeerInstance(tenantId, callerScopeId, verticalSlug.parse(vertical))),
3774
+ peerBinding: async (actor, tenantId, callerScopeId, vertical) => {
3775
+ const row = await this.cp.peerBinding(tenantId, callerScopeId, verticalSlug.parse(vertical));
3776
+ await this.recordAccess(actor, 'peerBinding', { tenantId, scopeId: callerScopeId }, { vertical }, row ? 1 : 0);
3777
+ return row ? peerBindingSchema.parse({ tenantId, callerScopeId, vertical, targetScopeId: row.target_scope_id, invalidated: row.invalidated === 1 }) : undefined;
3778
+ },
3779
+ peerBindings: async (actor, tenantId, callerScopeId) => {
3780
+ const rows = await this.cp.peerBindings(tenantId, callerScopeId);
3781
+ await this.recordAccess(actor, 'peerBindings', { tenantId, scopeId: callerScopeId }, null, rows.length);
3782
+ return rows.map((r) => peerBindingSchema.parse({ tenantId, callerScopeId, vertical: r.vertical, targetScopeId: r.target_scope_id, invalidated: r.invalidated === 1 }));
3783
+ },
3784
+ setPeerBinding: async (_actor, tenantId, callerScopeId, vertical, targetScopeId) => {
3785
+ const answer = await this.cp.setPeerBinding(tenantId, callerScopeId, verticalSlug.parse(vertical), targetScopeId);
3786
+ if (!answer.ok)
3787
+ throw substratError(answer.code, answer.message);
3788
+ return { changed: answer.changed, previous: answer.previous };
3789
+ },
3686
3790
  resolveHostname: async (raw) =>
3687
3791
  // The router's per-request read. No actor, not logged — the same machine-path
3688
3792
  // carve-out resolveIdentity has (K-24). Shares its mapping with the router's
@@ -3708,6 +3812,9 @@ export class CloudflareScopeHost {
3708
3812
  // Declared email-sender intent (#303) — the request half; the grant is its own column.
3709
3813
  if (parsed.sendsEmail)
3710
3814
  installSpec.sendsEmail = parsed.sendsEmail;
3815
+ // Where the scope lifecycle is held (#1713) — read by the lifecycle delivery's targets.
3816
+ if (parsed.lifecycle)
3817
+ installSpec.lifecycle = parsed.lifecycle;
3711
3818
  const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
3712
3819
  const existing = await this.cp.readVertical(parsed.slug);
3713
3820
  if (existing) {
@@ -4014,6 +4121,9 @@ export class CloudflareScopeHost {
4014
4121
  if (opts?.expectedVersionId !== undefined && scope.vertical_version_id !== opts.expectedVersionId) {
4015
4122
  throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
4016
4123
  }
4124
+ if (opts?.expectedErasureEpoch !== undefined && await this.cp.scopeErasureEpoch(tenantId, scopeId) !== opts.expectedErasureEpoch) {
4125
+ throw substratError('precondition_failed', 'scope erasure changed; reload the scope and retry');
4126
+ }
4017
4127
  const ack = bindAcknowledgement.parse(opts?.acknowledge ?? {});
4018
4128
  // #1756: an export an app in this tenant imports, dropped or re-versioned by what this
4019
4129
  // scope would run. Before the snapshot, so a refused bind leaves nothing behind.
@@ -4030,13 +4140,35 @@ export class CloudflareScopeHost {
4030
4140
  await this.snapshotScope(actor, tenantId, scopeId);
4031
4141
  }
4032
4142
  }
4033
- await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug, opts?.expectedVersionId);
4143
+ if (!await this.cp.bindScopeVersion(scopeId, versionId, v.vertical_slug, opts?.expectedVersionId, opts?.expectedErasureEpoch, opts?.confirmMove)) {
4144
+ throw substratError('precondition_failed', 'scope binding changed; reload the scope and retry');
4145
+ }
4034
4146
  await this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
4035
4147
  versionId, vertical: v.vertical_slug, version: v.version,
4036
4148
  ...(opts?.expectedVersionId !== undefined ? { expectedVersionId: opts.expectedVersionId } : {}),
4037
4149
  ...(ack.exportBreak ? { acknowledged: ack } : {}),
4038
4150
  });
4039
4151
  },
4152
+ recordScopeScriptCopy: async (_actor, tenantId, scopeId, scriptRef, moveId, opts) => {
4153
+ const result = await this.cp.recordScopeScriptCopy(tenantId, scopeId, scriptRef, moveId, opts?.role ?? null, opts?.loadStamp ?? null, ...(opts?.leaseMs !== undefined ? [opts.leaseMs] : []));
4154
+ if (result === 'invalid')
4155
+ throw substratError('conflict', 'copy script and move must name a real script');
4156
+ if (result === 'missing')
4157
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
4158
+ if (result === 'reaping')
4159
+ throw substratError('precondition_failed', `scope ${scopeId} is being reaped`);
4160
+ },
4161
+ beginScopeScriptReap: async (_actor, tenantId, scopeId) => {
4162
+ const result = await this.cp.beginScopeScriptReap(tenantId, scopeId);
4163
+ if (result === 'missing')
4164
+ throw substratError('not_found', `unknown scope ${scopeId} in tenant ${tenantId}`);
4165
+ if (result === 'pending')
4166
+ throw substratError('precondition_failed', `scope ${scopeId} has a copy move in flight; retry reap after it settles`);
4167
+ },
4168
+ claimExpiredScopeScriptCopies: async (_actor, input) => (await this.cp.claimExpiredScopeScriptCopies(input)).map(scopeScriptCopyOf),
4169
+ 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
+ touchScopeScriptCopy: async (_actor, tenantId, scopeId, scriptRef, moveId) => this.cp.touchScopeScriptCopy(tenantId, scopeId, scriptRef, moveId),
4171
+ listScopeScriptCopies: async (_actor, filter) => (await this.cp.listScopeScriptCopies(filter)).map(scopeScriptCopyOf),
4040
4172
  /**
4041
4173
  * Record that this scope's provision has now run against `versionId` (#1172).
4042
4174
  *
@@ -4112,7 +4244,9 @@ export class CloudflareScopeHost {
4112
4244
  if (breaks.length > 0)
4113
4245
  throw substratError('precondition_failed', bindExportBreakRefusal(breaks));
4114
4246
  }
4115
- await this.cp.setScopeServingRef(scopeId, servingRef);
4247
+ if (!await this.cp.setScopeServingRef(scopeId, servingRef, opts?.expectedErasureEpoch, opts?.confirmMove)) {
4248
+ throw substratError('precondition_failed', 'scope erasure or reap changed; reload and retry');
4249
+ }
4116
4250
  await this.recordAdmin(actor, 'setScopeServingRef', { tenantId, scopeId }, { servingRef: scope.serving_ref ?? null }, { servingRef, ...(ack.exportBreak ? { acknowledged: ack } : {}) });
4117
4251
  },
4118
4252
  recordKeptCopyResolution: async (actor, tenantId, scopeId, r) => {
@@ -4710,6 +4844,12 @@ export class CloudflareScopeHost {
4710
4844
  // Both spine copies (#1600): the outbox row AND any platform intent this event was
4711
4845
  // routed into. One RPC, so a crash cannot land half of it.
4712
4846
  const redacted = await this.scopeStub(scopeId).redactSubject(subjectId);
4847
+ // A refusal answered as data (#2068) — a module's `onSubjectErased` hook threw or reached
4848
+ // past its own tables, and the DO rolled the whole redaction back. Rethrown with its code,
4849
+ // before the key.
4850
+ if (typeof redacted === 'object' && 'failure' in redacted && redacted.failure) {
4851
+ throw fromWireFailure(redacted.failure);
4852
+ }
4713
4853
  // An OLD ScopeDO answers with a bare number — it redacted the outbox and never
4714
4854
  // looked at the intent journal. Refused here, BEFORE the key is destroyed, and
4715
4855
  // that order is the whole point: the key is the irreversible half, so proceeding
@@ -4742,7 +4882,16 @@ export class CloudflareScopeHost {
4742
4882
  `or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
4743
4883
  `leaving their data in those rows. Redeploy the vertical and re-run.`);
4744
4884
  }
4745
- const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, } = redacted;
4885
+ // A DO from before the module half (#2068): it redacted the spine and never ran a
4886
+ // module's declared erasure or its `onSubjectErased` hook. Refused before the key, for
4887
+ // the reason above — its reply read as "no module rows" would receipt an erasure that
4888
+ // left the person in the vertical's own tables, with the key already gone.
4889
+ if (!('vertical' in redacted) || !isModuleErasureCounts(redacted.vertical)) {
4890
+ throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO from before #2068, whose redaction does not reach a module's ` +
4891
+ `own tables — erasing now would destroy the subject key while leaving their data in the ` +
4892
+ `vertical's rows. Redeploy the vertical and re-run.`);
4893
+ }
4894
+ const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, vertical, } = redacted;
4746
4895
  // The directory's failure text (#1632) — a drain failure quoting one of those intents,
4747
4896
  // an issue's exemplar, a sweep record's error. Still before the key.
4748
4897
  await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
@@ -4753,6 +4902,7 @@ export class CloudflareScopeHost {
4753
4902
  eventsRedacted,
4754
4903
  intentsRedacted,
4755
4904
  jobRunsRedacted,
4905
+ ...vertical,
4756
4906
  keyDestroyed: existed,
4757
4907
  tombstoned: true,
4758
4908
  });
@@ -4762,7 +4912,33 @@ export class CloudflareScopeHost {
4762
4912
  await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
4763
4913
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
4764
4914
  // an intent payload is a whole event's worth of it.
4765
- await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
4915
+ await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults + moduleRowsErased(vertical));
4916
+ return receipt;
4917
+ },
4918
+ scopeErasureEpoch: async (_actor, tenantId, scopeId) => this.cp.scopeErasureEpoch(tenantId, scopeId),
4919
+ finalizeSubjectShred: async (actor, tenantId, scopeId, subjectId, redactions, expected) => {
4920
+ await this.assertScope(tenantId, scopeId);
4921
+ if (redactions.length === 0)
4922
+ throw substratError('conflict', 'subject erasure has no confirmed scope redaction');
4923
+ if (!await this.cp.claimSubjectErasure(tenantId, scopeId, expected.versionId, expected.servingRef, expected.epoch, expected.copyCount)) {
4924
+ throw substratError('precondition_failed', 'scope route or copy inventory changed during subject erasure; retry after it settles');
4925
+ }
4926
+ const intentIds = [...new Set(redactions.flatMap((r) => r.intentIds))];
4927
+ await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
4928
+ const { existed } = await this.subjectKeysFor(tenantId, scopeId).destroy(subjectId, new Date().toISOString());
4929
+ const receipt = subjectShredReceipt.parse({
4930
+ subjectId,
4931
+ eventsRedacted: redactions.reduce((n, r) => n + r.events, 0),
4932
+ intentsRedacted: redactions.reduce((n, r) => n + r.intents, 0),
4933
+ jobRunsRedacted: redactions.reduce((n, r) => n + r.jobRuns, 0),
4934
+ verticalRows: redactions.flatMap((r) => r.vertical.verticalRows),
4935
+ hookRows: redactions.flatMap((r) => r.vertical.hookRows),
4936
+ unreachedEntities: redactions.flatMap((r) => r.vertical.unreachedEntities),
4937
+ keyDestroyed: existed,
4938
+ tombstoned: true,
4939
+ });
4940
+ await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
4941
+ 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));
4766
4942
  return receipt;
4767
4943
  },
4768
4944
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -5005,12 +5181,12 @@ export class CloudflareScopeHost {
5005
5181
  throw new Error(`connection not found: ${id}`);
5006
5182
  const sealed = await this.secretBox.seal(JSON.stringify(connectionSecret.parse(secret)));
5007
5183
  const now = new Date().toISOString();
5008
- await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt ?? row.expires_at, now);
5184
+ await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt === undefined ? row.expires_at : expiresAt, now);
5009
5185
  await this.recordAdmin(actor, 'updateConnectionSecret', { tenantId: row.tenant_id, vertical: row.vertical }, null, {
5010
5186
  id,
5011
5187
  provider: row.provider,
5012
5188
  rotatedAt: now,
5013
- expiresAt: expiresAt ?? row.expires_at,
5189
+ expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
5014
5190
  // §3.5.1's attribution, rotate-side: the authorizing tenant principal,
5015
5191
  // never laundered into the actor column.
5016
5192
  ...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
@@ -5026,6 +5202,55 @@ export class CloudflareScopeHost {
5026
5202
  return; // idempotent, and a no-op is not audited
5027
5203
  await this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
5028
5204
  },
5205
+ // -- a vertical's mailed connect links (connections.md §3.5.4) --------------
5206
+ // Parsed here, run in the ControlPlaneDO as the kernel's statements (shared with
5207
+ // the pure adapter). Each move writes its audit row in the DO's own unit, from the
5208
+ // row minted here (actor, attribution) — so a failed audit write rolls the move back.
5209
+ // The DO has no clock of its own on this path — `now` is the coordinator's, the same
5210
+ // wall clock every other directory write uses.
5211
+ mintConnectLink: async (actor, raw) => {
5212
+ const input = mintConnectLinkInput.parse(raw);
5213
+ return this.cp.insertConnectLink({
5214
+ id: ulid(),
5215
+ tenantId: input.tenantId,
5216
+ scopeId: input.scopeId,
5217
+ vertical: input.vertical,
5218
+ provider: input.provider,
5219
+ createdBy: input.createdBy,
5220
+ subjectRef: input.subjectRef ?? null,
5221
+ returnUrl: input.returnUrl ?? null,
5222
+ createdAt: new Date().toISOString(),
5223
+ expiresAt: input.expiresAt,
5224
+ }, this.adminEntry(actor, 'mintConnectLink', { tenantId: input.tenantId }, null, null));
5225
+ },
5226
+ getConnectLink: async (actor, raw) => {
5227
+ const key = connectLinkKey.parse(raw);
5228
+ const link = await this.cp.readConnectLink(key);
5229
+ await this.recordAccess(actor, 'getConnectLink', key, { id: key.id }, link ? 1 : 0);
5230
+ return link;
5231
+ },
5232
+ listConnectLinks: async (actor, raw) => {
5233
+ const filter = connectLinkFilter.parse(raw);
5234
+ const links = await this.cp.listConnectLinks(filter, new Date().toISOString());
5235
+ await this.recordAccess(actor, 'listConnectLinks', filter, filter, links.length);
5236
+ return links;
5237
+ },
5238
+ revokeConnectLink: async (actor, raw) => {
5239
+ const key = connectLinkKey.parse(raw);
5240
+ // Idempotent, and a no-op is not audited (the statement hands back no row for one).
5241
+ const audit = this.adminEntry(actor, 'revokeConnectLink', { tenantId: key.tenantId }, null, null);
5242
+ return (await this.cp.revokeConnectLink(key, audit))?.link;
5243
+ },
5244
+ consumeConnectLink: async (actor, raw) => {
5245
+ const input = consumeConnectLinkInput.parse(raw);
5246
+ const audit = this.adminEntry(actor, 'consumeConnectLink', { tenantId: input.tenantId }, null, null);
5247
+ return this.cp.consumeConnectLink(input, new Date().toISOString(), audit);
5248
+ },
5249
+ restoreConnectLink: async (actor, raw) => {
5250
+ const key = connectLinkKey.parse(raw);
5251
+ const audit = this.adminEntry(actor, 'restoreConnectLink', { tenantId: key.tenantId }, null, null);
5252
+ return (await this.cp.restoreConnectLink(key, new Date().toISOString(), audit)) !== undefined;
5253
+ },
5029
5254
  openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
5030
5255
  const row = await this.cp.readLiveConnection(tenantId, vertical, provider, externalAccountRef);
5031
5256
  if (!row)
@@ -5127,6 +5352,18 @@ export class CloudflareScopeHost {
5127
5352
  const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
5128
5353
  await this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
5129
5354
  },
5355
+ recordPeerBindingChange: async (actor, entry) => {
5356
+ const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
5357
+ await this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
5358
+ },
5359
+ /** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
5360
+ auditedOperations: async (actor, refs) => {
5361
+ const rows = await this.cp.auditedOperations([...refs]);
5362
+ await this.recordAccess(actor, 'auditedOperations', {}, { operations: refs.length }, rows.length);
5363
+ return rows;
5364
+ },
5365
+ /** #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 }),
5130
5367
  /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
5131
5368
  recordCopyMark: async (actor, entry) => {
5132
5369
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
@@ -5724,7 +5961,7 @@ export class CloudflareScopeHost {
5724
5961
  }));
5725
5962
  }
5726
5963
  async writeScopeTuple(scopeId, subject, relation, object, expiresAt) {
5727
- await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt);
5964
+ unwrapReply(await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt));
5728
5965
  }
5729
5966
  /**
5730
5967
  * The connection's CURRENT public sealing key, minted on first ask (#687).
@@ -6115,11 +6352,17 @@ export class CloudflareScopeHost {
6115
6352
  * arrive as a different class of answer just because the caller asked for a socket.
6116
6353
  */
6117
6354
  liveReads = {
6118
- subscribe: async ({ tenantId, scopeId, principal, request, within }) => {
6355
+ subscribe: async ({ tenantId, scopeId, principal, request, within, expiresAt }) => {
6119
6356
  // Decided before anything else, and thrown: a `within` that is neither a plain
6120
6357
  // EntityRef nor a value `vouchedWithin` built is a caller bug, and the two ways of
6121
6358
  // guessing at it both open a feed wider than was asked for (#1853).
6122
6359
  const narrowed = liveWithinOf(within);
6360
+ // The same for an expiry that is not an instant: read as "none", it would keep the
6361
+ // socket open past the session it was meant to end with (#938).
6362
+ const expires = expiresAt === undefined ? undefined : liveInstant(expiresAt);
6363
+ if (expiresAt !== undefined && !expires) {
6364
+ throw substratError('validation_failed', 'live reads: `expiresAt` must be an ISO 8601 instant');
6365
+ }
6123
6366
  if (!isUpgradeRequest(request)) {
6124
6367
  return new Response('live reads are a WebSocket surface', {
6125
6368
  status: 426,
@@ -6181,6 +6424,10 @@ export class CloudflareScopeHost {
6181
6424
  headers.set(LIVE_WITHIN_HEADER, encodeLiveWithin(narrowed));
6182
6425
  else
6183
6426
  headers.delete(LIVE_WITHIN_HEADER);
6427
+ if (expires)
6428
+ headers.set(LIVE_EXPIRES_HEADER, expires);
6429
+ else
6430
+ headers.delete(LIVE_EXPIRES_HEADER);
6184
6431
  const forwarded = new Request(new URL(LIVE_SUBSCRIBE_PATH, 'https://scope.substrat.internal'),
6185
6432
  // `new Request(url, { …, headers })` rather than `new Request(request, …)`:
6186
6433
  // the DO is addressed by its own path, not the client's, and the upgrade
@@ -6438,6 +6685,8 @@ export class CloudflareScopeHost {
6438
6685
  // #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
6439
6686
  // same unit, on the scope this call provisions — the one the seat's tuples name.
6440
6687
  carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`)));
6688
+ if (input.entityGrants)
6689
+ await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants);
6441
6690
  return carry ? { switchedOff } : {};
6442
6691
  }
6443
6692
  /**
@@ -6463,7 +6712,7 @@ export class CloudflareScopeHost {
6463
6712
  * already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
6464
6713
  */
6465
6714
  async assignScopeRole(scopeId, principal, roleKey) {
6466
- await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null);
6715
+ unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
6467
6716
  }
6468
6717
  /**
6469
6718
  * Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
@@ -6522,8 +6771,41 @@ export class CloudflareScopeHost {
6522
6771
  * (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
6523
6772
  */
6524
6773
  async grantEntityLocal(scopeId, principal, permission, entity) {
6525
- await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
6526
- null);
6774
+ unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
6775
+ null));
6776
+ }
6777
+ /**
6778
+ * Give a person a declared entity-grant SHAPE on one entity in a CP-less vertical (#2071) —
6779
+ * `grantEntityLocal` for every key of the shape, plus the marker that makes them a holder of
6780
+ * it, in one unit. What a vertical calls where it used to loop `grantEntityLocal` over its
6781
+ * `ENTITY_GRANTS` keys: a key the shape gains later then reaches this person at the next
6782
+ * provision or reconcile (`provisionScopeLocal`'s `entityGrants`). Idempotent, and explicit
6783
+ * like `grantEntityLocal`: it brings back a key or a marker a revoke tombstoned.
6784
+ */
6785
+ async grantEntityShapeLocal(scopeId, principal, entity, permissions) {
6786
+ await this.scopeStub(scopeId).grantEntityShape(principal, entity, permissions);
6787
+ }
6788
+ /**
6789
+ * Top every holder of each declared shape up to the shape as it is now (#2071), in bounded
6790
+ * passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
6791
+ * anything else is `validation_failed`), repeated until a pass finishes. Never re-grants a revoked
6792
+ * 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.
6794
+ */
6795
+ async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
6796
+ const limit = shapeTopUpBatch(batch);
6797
+ let toppedUp = 0;
6798
+ let retired = 0;
6799
+ if (shapes.length === 0)
6800
+ return { toppedUp, retired };
6801
+ const stub = this.scopeStub(scopeId);
6802
+ for (let done = false; !done;) {
6803
+ const pass = await stub.topUpEntityGrantShapes(tenantId, scopeId, shapes, limit);
6804
+ toppedUp += pass.toppedUp;
6805
+ retired += pass.retired;
6806
+ done = pass.done;
6807
+ }
6808
+ return { toppedUp, retired };
6527
6809
  }
6528
6810
  // -- the connector write-back's far end (#574) -----------------------------
6529
6811
  // A CP-less dispatch vertical cannot run a connector, so the shared control
@@ -6722,10 +7004,21 @@ export class CloudflareScopeHost {
6722
7004
  if (!delegation)
6723
7005
  this.assertServedHere(rec, scopeId, 'moveImportCursor');
6724
7006
  await this.validateScopeAccess(tenantId, scopeId);
7007
+ // #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs and
7008
+ // closes the lever's rows exactly as it does the kill switches'.
6725
7009
  const replayId = ulid();
6726
7010
  const target = { tenantId, scopeId, vertical: rec.vertical };
6727
- const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
7011
+ const base = { operationId: replayId, replayId, mode: move.mode, from: move.from, source: source.scopeId };
6728
7012
  await this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
7013
+ // #2114: every outcome row goes through the kernel's `recordAuditOutcome`, as the kill
7014
+ // switches' do (#2089). One that cannot be written is logged with the replay id, never
7015
+ // swallowed; the call still answers with the move's own error (or, applied, with the move and
7016
+ // `auditWarning`), and the scheduled settle closes the intent as `unknown`.
7017
+ const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, ...row }), {
7018
+ flow: 'import-cursor',
7019
+ operationId: replayId,
7020
+ phase: row.phase,
7021
+ }, (message, fields) => console.error(message, fields));
6729
7022
  const at = { move, source: source, replayId };
6730
7023
  let moved;
6731
7024
  try {
@@ -6734,21 +7027,16 @@ export class CloudflareScopeHost {
6734
7027
  : await this.moveInScope(scopeId, at);
6735
7028
  }
6736
7029
  catch (err) {
6737
- await this.recordAdmin(actor, 'moveImportCursor', target, null, {
6738
- ...base,
6739
- phase: 'failed',
6740
- error: err instanceof Error ? err.message : String(err),
6741
- }).catch(() => undefined);
7030
+ await recordOutcome({ phase: 'failed', error: err instanceof Error ? err.message : String(err) });
6742
7031
  throw err;
6743
7032
  }
6744
- await this.recordAdmin(actor, 'moveImportCursor', target, null, {
6745
- ...base,
7033
+ const unrecorded = await recordOutcome({
6746
7034
  phase: 'applied',
6747
7035
  previous: moved.previous,
6748
7036
  cursor: moved.cursor,
6749
7037
  archived: moved.archived,
6750
7038
  });
6751
- return moved;
7039
+ return unrecorded === null ? moved : { ...moved, auditWarning: auditWarningOf('the move', unrecorded) };
6752
7040
  }
6753
7041
  /** #1705 PR 3: the promote gate's question, over two stored manifests (`exportBreaksOf`). */
6754
7042
  exportBreaksBetween(actor, producer, outgoingManifest, incomingManifest) {