@substrat-run/adapter-cloudflare 0.139.0 → 0.140.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,11 @@
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, 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 { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased } 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, LIVE_EXPIRES_HEADER, liveInstant, encodeLiveWithin, liveWithinOf, } from './live-reads.js';
9
9
  import { tenantStoreDatabaseName } from './d1.js';
10
10
  import { blobStoreBucketName, r2TenantBlobStore } from './r2.js';
11
11
  import { unwrapReply } from './do-reply.js';
@@ -777,6 +777,12 @@ export class CloudflareScopeHost {
777
777
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
778
778
  await this.validateScopeAccess(tenantId, scopeId);
779
779
  await this.migrateAndRecord(scopeId);
780
+ // #2102: an outcome event travels by the verb that writes it; every other settle keeps the
781
+ // positional one, so it still reaches a DO class that predates the event.
782
+ if (outcome.event) {
783
+ await this.scopeStub(scopeId).settlePlatformRequestWithEvent(tenantId, scopeId, id, outcome);
784
+ return;
785
+ }
780
786
  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
787
  }
782
788
  migrationFrontier() {
@@ -1219,6 +1225,9 @@ export class CloudflareScopeHost {
1219
1225
  if (this.moduleIds.has(manifest.id)) {
1220
1226
  throw new Error(`module already registered: ${manifest.id}`);
1221
1227
  }
1228
+ // #2068: the same refusal the ScopeDO applies at code time, here at registration, so a
1229
+ // misdeclared erasure fails where it is registered on both hosts.
1230
+ moduleErasurePlan(registration);
1222
1231
  const migrations = registration.migrations ?? [];
1223
1232
  const seen = new Set();
1224
1233
  for (const m of migrations) {
@@ -1241,6 +1250,9 @@ export class CloudflareScopeHost {
1241
1250
  }
1242
1251
  this.predicateNames.set(name, manifest.id);
1243
1252
  }
1253
+ // #119: the trash rules, refused here too so a bad module fails at construction, not at its
1254
+ // scope's first wake. The scope holds the targets; the coordinator only needs the verdict.
1255
+ registerTrashTargets(manifest.id, new Set(Object.keys(registration.operations ?? {})), registration.operationInputs, manifest.entityStates, manifest.schedules);
1244
1256
  // #1705: after the checks above, so a refused module leaves nothing registered here.
1245
1257
  this.crossVertical.register(manifest, registration.imports);
1246
1258
  this.moduleIds.add(manifest.id);
@@ -2450,22 +2462,41 @@ export class CloudflareScopeHost {
2450
2462
  const startedAt = Date.now();
2451
2463
  let emitted;
2452
2464
  let failure;
2465
+ // #119, #2096: more of a purge horizon's lap is due — the schedule stays due, so the next pass continues it.
2466
+ let stillDue = false;
2453
2467
  try {
2454
2468
  // The gate above already answered for this pass; a fire that meets a restarted scope
2455
2469
  // is gated again by the door (#1834).
2456
2470
  door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
2457
2471
  const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door);
2458
- await scope.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
2472
+ if (schedule.purge) {
2473
+ // #119: a purge horizon's schedule runs its operation once per due entity, each its own
2474
+ // call and transaction — and the SCOPE decides which are due and runs them, so purge
2475
+ // authority is never an argument any stub can pass (`runPurgeSweep`).
2476
+ const pass = await door.through((instance) => stub.runPurgeSweep(schedule.operation, tenantId, scopeId, instance));
2477
+ // The purges' events, delivered the way each invoke's own tail delivers them.
2478
+ await this.drainExecutors(tenantId, scopeId, null);
2479
+ stillDue = purgeStillDue(pass);
2480
+ const outcome = purgeReportOf(schedule.operation, schedule.purge.entityType, pass);
2481
+ report.errors.push(...outcome.errors);
2482
+ if (outcome.failure)
2483
+ throw outcome.failure;
2484
+ }
2485
+ else {
2486
+ await scope.invoke(schedule.operation, schedule.input, { invocationId, onEmitted: (r) => (emitted = r) });
2487
+ }
2459
2488
  report.fired += 1;
2460
2489
  }
2461
2490
  catch (err) {
2462
2491
  status = 'failed';
2463
2492
  failure = { error: err };
2464
2493
  report.failed += 1;
2465
- report.errors.push({
2466
- operation: schedule.operation,
2467
- error: err instanceof Error ? err.message : String(err),
2468
- });
2494
+ if (!schedule.purge) {
2495
+ report.errors.push({
2496
+ operation: schedule.operation,
2497
+ error: err instanceof Error ? err.message : String(err),
2498
+ });
2499
+ }
2469
2500
  }
2470
2501
  lines.write({
2471
2502
  kind: 'schedule',
@@ -2480,10 +2511,12 @@ export class CloudflareScopeHost {
2480
2511
  latenessMs: lastRun === null ? null : now - dueAt,
2481
2512
  ...(emitted ? { emitted } : {}),
2482
2513
  });
2514
+ report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
2515
+ if (stillDue)
2516
+ continue;
2483
2517
  // #1288: 'schedule', whatever this operation happens to be called — including
2484
2518
  // `freshness:<something>`, which is exactly the row the evaluator no longer eats.
2485
2519
  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
2520
  }
2488
2521
  lines.end();
2489
2522
  return report;
@@ -2548,6 +2581,9 @@ export class CloudflareScopeHost {
2548
2581
  ? 'vertical'
2549
2582
  : undefined,
2550
2583
  invoke: async (operation, input, invokeOptions) => {
2584
+ // #119: purge authority is never an invoke option — only the scope's own `runPurgeSweep`
2585
+ // purges — and an options object naming a cutoff is refused outright.
2586
+ assertNoCallerPurge(invokeOptions);
2551
2587
  // Entitlement gate (§4.3): a module loads for a tenant only if the tenant holds its
2552
2588
  // SKU flag. The COORDINATOR gates the console-managed path against the shared CP
2553
2589
  // (`cp.tenantHoldsEntitlement`); for a hosted/CP-less scope that call is a trusting
@@ -2950,11 +2986,21 @@ export class CloudflareScopeHost {
2950
2986
  // admin log are separate, so no order makes the pair atomic; this one fails toward
2951
2987
  // "an intent with no recorded outcome" and never toward "a switch that moved with no
2952
2988
  // audit row". A retry after a crash re-audits even though it answers `changed: false`.
2989
+ //
2990
+ // #2089: every outcome row goes through the kernel's `recordAuditOutcome`, as the control
2991
+ // plane's audited changes do. One that cannot be written is logged with the operation id,
2992
+ // never swallowed; the call still answers with its own error (or, applied, with success and
2993
+ // `auditWarning`), and the scheduled settle closes the intent as `unknown`.
2953
2994
  const operationId = ulid();
2954
2995
  const target = { tenantId, scopeId, vertical };
2955
2996
  const base = { operationId, ...switchAuditSubject(kind, key, to) };
2956
2997
  await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
2957
2998
  const errorOf = (err) => (err instanceof Error ? err.message : String(err));
2999
+ const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, action, target, null, { ...base, ...row }), {
3000
+ flow: `${kind}-switch`,
3001
+ operationId,
3002
+ phase: row.phase,
3003
+ }, (message, fields) => console.error(message, fields));
2958
3004
  // #2045 (Codex r3): the deployment's fence is attested BEFORE anything is recorded or moved. An
2959
3005
  // ON on a deployment built before it is refused here, with nothing written anywhere but the
2960
3006
  // audit. An OFF goes on: the kill switch must work on every deployment (see `UnattestedSwitch`).
@@ -2964,7 +3010,7 @@ export class CloudflareScopeHost {
2964
3010
  }
2965
3011
  }
2966
3012
  catch (err) {
2967
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', error: errorOf(err) }).catch(() => undefined);
3013
+ await recordOutcome({ phase: 'refused', error: errorOf(err) });
2968
3014
  throw err;
2969
3015
  }
2970
3016
  // The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
@@ -2982,13 +3028,13 @@ export class CloudflareScopeHost {
2982
3028
  // Nothing has moved: fail the call here, audited. An ON must not switch a scope on
2983
3029
  // whose record still says off (the next reconcile would switch it back off), and an
2984
3030
  // 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);
3031
+ await recordOutcome({ phase: 'failed', error: errorOf(err) });
2986
3032
  throw err;
2987
3033
  }
2988
3034
  // #2045: a newer call on this subject has recorded its position already: this one writes
2989
3035
  // nothing, here or in the scope, and says so.
2990
3036
  if (recordWriteSuperseded(prior, record)) {
2991
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
3037
+ await recordOutcome({ phase: 'refused', superseded: true });
2992
3038
  throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
2993
3039
  }
2994
3040
  let outcome;
@@ -3010,13 +3056,12 @@ export class CloudflareScopeHost {
3010
3056
  // readback can settle the scope and nothing is put back: the record is this call's position
3011
3057
  // under the newest fence, and its write-ahead mark makes the next re-assert move the scope
3012
3058
  // to it, under that fence, whichever way it lies now.
3013
- await this.recordAdmin(actor, action, target, null, {
3014
- ...base,
3059
+ await recordOutcome({
3015
3060
  phase: err instanceof UnattestedSwitch ? 'refused' : 'failed',
3016
3061
  error: errorOf(err),
3017
3062
  recordKept: true,
3018
3063
  reassertOwed: true,
3019
- }).catch(() => undefined);
3064
+ });
3020
3065
  if (err instanceof UnattestedSwitch)
3021
3066
  throw substratError('precondition_failed', err.message);
3022
3067
  throw err;
@@ -3025,7 +3070,7 @@ export class CloudflareScopeHost {
3025
3070
  // record is that newer call's too (its write overwrote this one's, mark included), so nothing
3026
3071
  // is undone and no mark is this call's to clear.
3027
3072
  if (outcome.superseded) {
3028
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
3073
+ await recordOutcome({ phase: 'refused', superseded: true });
3029
3074
  throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
3030
3075
  }
3031
3076
  // A call that held nothing moved nothing, so its record write is undone: left `off`, it
@@ -3052,8 +3097,7 @@ export class CloudflareScopeHost {
3052
3097
  else if (!unfenced) {
3053
3098
  owedError = await this.cp.clearSwitchOwed(kind, tenantId, scopeId, key, operationId).then(() => null, errorOf);
3054
3099
  }
3055
- await this.recordAdmin(actor, action, target, null, {
3056
- ...base,
3100
+ const unrecorded = await recordOutcome({
3057
3101
  phase: outcome.held ? 'applied' : 'refused',
3058
3102
  changed: outcome.changed,
3059
3103
  permissions: outcome.permissions,
@@ -3065,30 +3109,32 @@ export class CloudflareScopeHost {
3065
3109
  throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
3066
3110
  (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
3067
3111
  }
3068
- return { operationId, outcome };
3112
+ return { operationId, outcome, ...(unrecorded !== null ? { auditWarning: auditWarningOf('the switch', unrecorded) } : {}) };
3069
3113
  };
3070
3114
  /** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
3071
3115
  const switchSystem = async (actor, raw, to) => {
3072
3116
  const input = systemSwitch.parse(raw);
3073
- const { operationId, outcome } = await switchSubjectAt(actor, 'system', input.node, input.moduleId, input.reason, to);
3117
+ const { operationId, outcome, auditWarning } = await switchSubjectAt(actor, 'system', input.node, input.moduleId, input.reason, to);
3074
3118
  return {
3075
3119
  operationId,
3076
3120
  moduleId: input.moduleId,
3077
3121
  schedules: to,
3078
3122
  changed: outcome.changed,
3079
3123
  permissions: outcome.permissions,
3124
+ ...(auditWarning ? { auditWarning } : {}),
3080
3125
  };
3081
3126
  };
3082
3127
  /** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
3083
3128
  const switchPeerAt = async (actor, raw, to) => {
3084
3129
  const input = peerSwitch.parse(raw);
3085
- const { operationId, outcome } = await switchSubjectAt(actor, 'peer', input.node, input.vertical, input.reason, to);
3130
+ const { operationId, outcome, auditWarning } = await switchSubjectAt(actor, 'peer', input.node, input.vertical, input.reason, to);
3086
3131
  return {
3087
3132
  operationId,
3088
3133
  vertical: input.vertical,
3089
3134
  calls: to,
3090
3135
  changed: outcome.changed,
3091
3136
  permissions: outcome.permissions,
3137
+ ...(auditWarning ? { auditWarning } : {}),
3092
3138
  };
3093
3139
  };
3094
3140
  /**
@@ -3355,6 +3401,8 @@ export class CloudflareScopeHost {
3355
3401
  return result;
3356
3402
  };
3357
3403
  return {
3404
+ // #2069: a view of the view this admin was built over — the person added, its event kept.
3405
+ attributed: (onBehalfOf) => this.attributed(onBehalfOf).admin,
3358
3406
  // #603: fixed at construction — a worker deployed without SECRET_BOX_KEY can never
3359
3407
  // store a credential, and saying so is what lets a transport answer 503 instead of 500.
3360
3408
  canStoreSecrets: isSecretBoxConfigured(this.secretBox),
@@ -3427,6 +3475,17 @@ export class CloudflareScopeHost {
3427
3475
  if (!grant.node.scopeId)
3428
3476
  await this.fanOut(grant.node.tenantId);
3429
3477
  },
3478
+ grantEntityShape: async (actor, grant) => {
3479
+ await this.grantEntityShapeLocal(grant.node.scopeId, grant.principalId, grant.entity, grant.permissions);
3480
+ await this.recordAdmin(actor, 'grantEntityShape', grant.node, null, grant);
3481
+ },
3482
+ reconcileEntityGrantShapes: async (actor, node, shapes, opts) => {
3483
+ const { toppedUp, retired } = await this.topUpEntityGrantShapesLocal(node.tenantId, node.scopeId, shapes, opts?.batch);
3484
+ if (toppedUp > 0 || retired > 0) {
3485
+ await this.recordAdmin(actor, 'reconcileEntityGrantShapes', node, null, { shapes, toppedUp, retired });
3486
+ }
3487
+ return { toppedUp, retired };
3488
+ },
3430
3489
  grantToConnection: async (actor, raw) => {
3431
3490
  const grant = connectionGrant.parse(raw);
3432
3491
  const conn = await this.cp.readConnection(grant.connectionId);
@@ -3683,6 +3742,23 @@ export class CloudflareScopeHost {
3683
3742
  // #1706: the directory's answer to "vertical Y in tenant T", in the ControlPlaneDO, by
3684
3743
  // the kernel's one rule. No actor, not logged — `resolveHostname`'s machine-path reason.
3685
3744
  resolveVerticalInstance: async (tenantId, vertical) => verticalResolution.parse(await this.cp.resolveVerticalInstance(tenantId, verticalSlug.parse(vertical))),
3745
+ resolvePeerInstance: async (tenantId, callerScopeId, vertical) => peerInstanceResolution.parse(await this.cp.resolvePeerInstance(tenantId, callerScopeId, verticalSlug.parse(vertical))),
3746
+ peerBinding: async (actor, tenantId, callerScopeId, vertical) => {
3747
+ const row = await this.cp.peerBinding(tenantId, callerScopeId, verticalSlug.parse(vertical));
3748
+ await this.recordAccess(actor, 'peerBinding', { tenantId, scopeId: callerScopeId }, { vertical }, row ? 1 : 0);
3749
+ return row ? peerBindingSchema.parse({ tenantId, callerScopeId, vertical, targetScopeId: row.target_scope_id, invalidated: row.invalidated === 1 }) : undefined;
3750
+ },
3751
+ peerBindings: async (actor, tenantId, callerScopeId) => {
3752
+ const rows = await this.cp.peerBindings(tenantId, callerScopeId);
3753
+ await this.recordAccess(actor, 'peerBindings', { tenantId, scopeId: callerScopeId }, null, rows.length);
3754
+ return rows.map((r) => peerBindingSchema.parse({ tenantId, callerScopeId, vertical: r.vertical, targetScopeId: r.target_scope_id, invalidated: r.invalidated === 1 }));
3755
+ },
3756
+ setPeerBinding: async (_actor, tenantId, callerScopeId, vertical, targetScopeId) => {
3757
+ const answer = await this.cp.setPeerBinding(tenantId, callerScopeId, verticalSlug.parse(vertical), targetScopeId);
3758
+ if (!answer.ok)
3759
+ throw substratError(answer.code, answer.message);
3760
+ return { changed: answer.changed, previous: answer.previous };
3761
+ },
3686
3762
  resolveHostname: async (raw) =>
3687
3763
  // The router's per-request read. No actor, not logged — the same machine-path
3688
3764
  // carve-out resolveIdentity has (K-24). Shares its mapping with the router's
@@ -3708,6 +3784,9 @@ export class CloudflareScopeHost {
3708
3784
  // Declared email-sender intent (#303) — the request half; the grant is its own column.
3709
3785
  if (parsed.sendsEmail)
3710
3786
  installSpec.sendsEmail = parsed.sendsEmail;
3787
+ // Where the scope lifecycle is held (#1713) — read by the lifecycle delivery's targets.
3788
+ if (parsed.lifecycle)
3789
+ installSpec.lifecycle = parsed.lifecycle;
3711
3790
  const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
3712
3791
  const existing = await this.cp.readVertical(parsed.slug);
3713
3792
  if (existing) {
@@ -4710,6 +4789,12 @@ export class CloudflareScopeHost {
4710
4789
  // Both spine copies (#1600): the outbox row AND any platform intent this event was
4711
4790
  // routed into. One RPC, so a crash cannot land half of it.
4712
4791
  const redacted = await this.scopeStub(scopeId).redactSubject(subjectId);
4792
+ // A refusal answered as data (#2068) — a module's `onSubjectErased` hook threw or reached
4793
+ // past its own tables, and the DO rolled the whole redaction back. Rethrown with its code,
4794
+ // before the key.
4795
+ if (typeof redacted === 'object' && 'failure' in redacted && redacted.failure) {
4796
+ throw fromWireFailure(redacted.failure);
4797
+ }
4713
4798
  // An OLD ScopeDO answers with a bare number — it redacted the outbox and never
4714
4799
  // looked at the intent journal. Refused here, BEFORE the key is destroyed, and
4715
4800
  // that order is the whole point: the key is the irreversible half, so proceeding
@@ -4742,7 +4827,16 @@ export class CloudflareScopeHost {
4742
4827
  `or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
4743
4828
  `leaving their data in those rows. Redeploy the vertical and re-run.`);
4744
4829
  }
4745
- const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, } = redacted;
4830
+ // A DO from before the module half (#2068): it redacted the spine and never ran a
4831
+ // module's declared erasure or its `onSubjectErased` hook. Refused before the key, for
4832
+ // the reason above — its reply read as "no module rows" would receipt an erasure that
4833
+ // left the person in the vertical's own tables, with the key already gone.
4834
+ if (!('vertical' in redacted) || !isModuleErasureCounts(redacted.vertical)) {
4835
+ throw substratError('unavailable', `scope ${scopeId} runs a ScopeDO from before #2068, whose redaction does not reach a module's ` +
4836
+ `own tables — erasing now would destroy the subject key while leaving their data in the ` +
4837
+ `vertical's rows. Redeploy the vertical and re-run.`);
4838
+ }
4839
+ const { events: eventsRedacted, intents: intentsRedacted, jobRuns: jobRunsRedacted, idempotencyResults, intentIds, vertical, } = redacted;
4746
4840
  // The directory's failure text (#1632) — a drain failure quoting one of those intents,
4747
4841
  // an issue's exemplar, a sweep record's error. Still before the key.
4748
4842
  await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
@@ -4753,6 +4847,7 @@ export class CloudflareScopeHost {
4753
4847
  eventsRedacted,
4754
4848
  intentsRedacted,
4755
4849
  jobRunsRedacted,
4850
+ ...vertical,
4756
4851
  keyDestroyed: existed,
4757
4852
  tombstoned: true,
4758
4853
  });
@@ -4762,7 +4857,7 @@ export class CloudflareScopeHost {
4762
4857
  await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
4763
4858
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
4764
4859
  // an intent payload is a whole event's worth of it.
4765
- await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults);
4860
+ await this.recordAccess(actor, 'shredSubject', { tenantId, scopeId }, { subjectId }, eventsRedacted + intentsRedacted + jobRunsRedacted + idempotencyResults + moduleRowsErased(vertical));
4766
4861
  return receipt;
4767
4862
  },
4768
4863
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -5005,12 +5100,12 @@ export class CloudflareScopeHost {
5005
5100
  throw new Error(`connection not found: ${id}`);
5006
5101
  const sealed = await this.secretBox.seal(JSON.stringify(connectionSecret.parse(secret)));
5007
5102
  const now = new Date().toISOString();
5008
- await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt ?? row.expires_at, now);
5103
+ await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt === undefined ? row.expires_at : expiresAt, now);
5009
5104
  await this.recordAdmin(actor, 'updateConnectionSecret', { tenantId: row.tenant_id, vertical: row.vertical }, null, {
5010
5105
  id,
5011
5106
  provider: row.provider,
5012
5107
  rotatedAt: now,
5013
- expiresAt: expiresAt ?? row.expires_at,
5108
+ expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
5014
5109
  // §3.5.1's attribution, rotate-side: the authorizing tenant principal,
5015
5110
  // never laundered into the actor column.
5016
5111
  ...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
@@ -5026,6 +5121,55 @@ export class CloudflareScopeHost {
5026
5121
  return; // idempotent, and a no-op is not audited
5027
5122
  await this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
5028
5123
  },
5124
+ // -- a vertical's mailed connect links (connections.md §3.5.4) --------------
5125
+ // Parsed here, run in the ControlPlaneDO as the kernel's statements (shared with
5126
+ // the pure adapter). Each move writes its audit row in the DO's own unit, from the
5127
+ // row minted here (actor, attribution) — so a failed audit write rolls the move back.
5128
+ // The DO has no clock of its own on this path — `now` is the coordinator's, the same
5129
+ // wall clock every other directory write uses.
5130
+ mintConnectLink: async (actor, raw) => {
5131
+ const input = mintConnectLinkInput.parse(raw);
5132
+ return this.cp.insertConnectLink({
5133
+ id: ulid(),
5134
+ tenantId: input.tenantId,
5135
+ scopeId: input.scopeId,
5136
+ vertical: input.vertical,
5137
+ provider: input.provider,
5138
+ createdBy: input.createdBy,
5139
+ subjectRef: input.subjectRef ?? null,
5140
+ returnUrl: input.returnUrl ?? null,
5141
+ createdAt: new Date().toISOString(),
5142
+ expiresAt: input.expiresAt,
5143
+ }, this.adminEntry(actor, 'mintConnectLink', { tenantId: input.tenantId }, null, null));
5144
+ },
5145
+ getConnectLink: async (actor, raw) => {
5146
+ const key = connectLinkKey.parse(raw);
5147
+ const link = await this.cp.readConnectLink(key);
5148
+ await this.recordAccess(actor, 'getConnectLink', key, { id: key.id }, link ? 1 : 0);
5149
+ return link;
5150
+ },
5151
+ listConnectLinks: async (actor, raw) => {
5152
+ const filter = connectLinkFilter.parse(raw);
5153
+ const links = await this.cp.listConnectLinks(filter, new Date().toISOString());
5154
+ await this.recordAccess(actor, 'listConnectLinks', filter, filter, links.length);
5155
+ return links;
5156
+ },
5157
+ revokeConnectLink: async (actor, raw) => {
5158
+ const key = connectLinkKey.parse(raw);
5159
+ // Idempotent, and a no-op is not audited (the statement hands back no row for one).
5160
+ const audit = this.adminEntry(actor, 'revokeConnectLink', { tenantId: key.tenantId }, null, null);
5161
+ return (await this.cp.revokeConnectLink(key, audit))?.link;
5162
+ },
5163
+ consumeConnectLink: async (actor, raw) => {
5164
+ const input = consumeConnectLinkInput.parse(raw);
5165
+ const audit = this.adminEntry(actor, 'consumeConnectLink', { tenantId: input.tenantId }, null, null);
5166
+ return this.cp.consumeConnectLink(input, new Date().toISOString(), audit);
5167
+ },
5168
+ restoreConnectLink: async (actor, raw) => {
5169
+ const key = connectLinkKey.parse(raw);
5170
+ const audit = this.adminEntry(actor, 'restoreConnectLink', { tenantId: key.tenantId }, null, null);
5171
+ return (await this.cp.restoreConnectLink(key, new Date().toISOString(), audit)) !== undefined;
5172
+ },
5029
5173
  openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
5030
5174
  const row = await this.cp.readLiveConnection(tenantId, vertical, provider, externalAccountRef);
5031
5175
  if (!row)
@@ -5127,6 +5271,18 @@ export class CloudflareScopeHost {
5127
5271
  const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
5128
5272
  await this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
5129
5273
  },
5274
+ recordPeerBindingChange: async (actor, entry) => {
5275
+ const { tenantId, scopeId, ...after } = peerBindingAudit.parse(entry);
5276
+ await this.recordAdmin(actor, 'setPeerBinding', { tenantId, scopeId }, null, after);
5277
+ },
5278
+ /** #2064: an audited operation's rows, by the operation-id index, in bounded batches. */
5279
+ auditedOperations: async (actor, refs) => {
5280
+ const rows = await this.cp.auditedOperations([...refs]);
5281
+ await this.recordAccess(actor, 'auditedOperations', {}, { operations: refs.length }, rows.length);
5282
+ return rows;
5283
+ },
5284
+ /** #2064: settle an intent with no outcome — one unit in the directory DO. */
5285
+ settleUnrecordedOutcome: (actor, input) => this.cp.settleUnrecordedOutcome({ actor, intentId: input.intentId, error: input.error }),
5130
5286
  /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
5131
5287
  recordCopyMark: async (actor, entry) => {
5132
5288
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
@@ -5724,7 +5880,7 @@ export class CloudflareScopeHost {
5724
5880
  }));
5725
5881
  }
5726
5882
  async writeScopeTuple(scopeId, subject, relation, object, expiresAt) {
5727
- await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt);
5883
+ unwrapReply(await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt));
5728
5884
  }
5729
5885
  /**
5730
5886
  * The connection's CURRENT public sealing key, minted on first ask (#687).
@@ -6115,11 +6271,17 @@ export class CloudflareScopeHost {
6115
6271
  * arrive as a different class of answer just because the caller asked for a socket.
6116
6272
  */
6117
6273
  liveReads = {
6118
- subscribe: async ({ tenantId, scopeId, principal, request, within }) => {
6274
+ subscribe: async ({ tenantId, scopeId, principal, request, within, expiresAt }) => {
6119
6275
  // Decided before anything else, and thrown: a `within` that is neither a plain
6120
6276
  // EntityRef nor a value `vouchedWithin` built is a caller bug, and the two ways of
6121
6277
  // guessing at it both open a feed wider than was asked for (#1853).
6122
6278
  const narrowed = liveWithinOf(within);
6279
+ // The same for an expiry that is not an instant: read as "none", it would keep the
6280
+ // socket open past the session it was meant to end with (#938).
6281
+ const expires = expiresAt === undefined ? undefined : liveInstant(expiresAt);
6282
+ if (expiresAt !== undefined && !expires) {
6283
+ throw substratError('validation_failed', 'live reads: `expiresAt` must be an ISO 8601 instant');
6284
+ }
6123
6285
  if (!isUpgradeRequest(request)) {
6124
6286
  return new Response('live reads are a WebSocket surface', {
6125
6287
  status: 426,
@@ -6181,6 +6343,10 @@ export class CloudflareScopeHost {
6181
6343
  headers.set(LIVE_WITHIN_HEADER, encodeLiveWithin(narrowed));
6182
6344
  else
6183
6345
  headers.delete(LIVE_WITHIN_HEADER);
6346
+ if (expires)
6347
+ headers.set(LIVE_EXPIRES_HEADER, expires);
6348
+ else
6349
+ headers.delete(LIVE_EXPIRES_HEADER);
6184
6350
  const forwarded = new Request(new URL(LIVE_SUBSCRIBE_PATH, 'https://scope.substrat.internal'),
6185
6351
  // `new Request(url, { …, headers })` rather than `new Request(request, …)`:
6186
6352
  // the DO is addressed by its own path, not the client's, and the upgrade
@@ -6438,6 +6604,8 @@ export class CloudflareScopeHost {
6438
6604
  // #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
6439
6605
  // same unit, on the scope this call provisions — the one the seat's tuples name.
6440
6606
  carry ? { scopeId: input.scopeId, at: new Date().toISOString(), ...carry } : undefined, services?.map((id) => `principal:${id}`)));
6607
+ if (input.entityGrants)
6608
+ await this.topUpEntityGrantShapesLocal(input.tenantId, input.scopeId, input.entityGrants);
6441
6609
  return carry ? { switchedOff } : {};
6442
6610
  }
6443
6611
  /**
@@ -6463,7 +6631,7 @@ export class CloudflareScopeHost {
6463
6631
  * already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
6464
6632
  */
6465
6633
  async assignScopeRole(scopeId, principal, roleKey) {
6466
- await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null);
6634
+ unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `role:${roleKey}`, `scope:${scopeId}`, null));
6467
6635
  }
6468
6636
  /**
6469
6637
  * Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
@@ -6522,8 +6690,41 @@ export class CloudflareScopeHost {
6522
6690
  * (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
6523
6691
  */
6524
6692
  async grantEntityLocal(scopeId, principal, permission, entity) {
6525
- await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
6526
- null);
6693
+ unwrapReply(await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
6694
+ null));
6695
+ }
6696
+ /**
6697
+ * Give a person a declared entity-grant SHAPE on one entity in a CP-less vertical (#2071) —
6698
+ * `grantEntityLocal` for every key of the shape, plus the marker that makes them a holder of
6699
+ * it, in one unit. What a vertical calls where it used to loop `grantEntityLocal` over its
6700
+ * `ENTITY_GRANTS` keys: a key the shape gains later then reaches this person at the next
6701
+ * provision or reconcile (`provisionScopeLocal`'s `entityGrants`). Idempotent, and explicit
6702
+ * like `grantEntityLocal`: it brings back a key or a marker a revoke tombstoned.
6703
+ */
6704
+ async grantEntityShapeLocal(scopeId, principal, entity, permissions) {
6705
+ await this.scopeStub(scopeId).grantEntityShape(principal, entity, permissions);
6706
+ }
6707
+ /**
6708
+ * Top every holder of each declared shape up to the shape as it is now (#2071), in bounded
6709
+ * passes — at most `batch` rows of work per scope transaction (default 500, at most 5000;
6710
+ * anything else is `validation_failed`), repeated until a pass finishes. Never re-grants a revoked
6711
+ * key; takes back only a key the shape declares `retired` (#2082). Returns how many
6712
+ * (person, entity) it topped up, and how many it took retired keys from.
6713
+ */
6714
+ async topUpEntityGrantShapesLocal(tenantId, scopeId, shapes, batch) {
6715
+ const limit = shapeTopUpBatch(batch);
6716
+ let toppedUp = 0;
6717
+ let retired = 0;
6718
+ if (shapes.length === 0)
6719
+ return { toppedUp, retired };
6720
+ const stub = this.scopeStub(scopeId);
6721
+ for (let done = false; !done;) {
6722
+ const pass = await stub.topUpEntityGrantShapes(tenantId, scopeId, shapes, limit);
6723
+ toppedUp += pass.toppedUp;
6724
+ retired += pass.retired;
6725
+ done = pass.done;
6726
+ }
6727
+ return { toppedUp, retired };
6527
6728
  }
6528
6729
  // -- the connector write-back's far end (#574) -----------------------------
6529
6730
  // A CP-less dispatch vertical cannot run a connector, so the shared control
@@ -6722,10 +6923,21 @@ export class CloudflareScopeHost {
6722
6923
  if (!delegation)
6723
6924
  this.assertServedHere(rec, scopeId, 'moveImportCursor');
6724
6925
  await this.validateScopeAccess(tenantId, scopeId);
6926
+ // #2114: the replay's id is also its audit `operationId`, so the scheduled settle pairs and
6927
+ // closes the lever's rows exactly as it does the kill switches'.
6725
6928
  const replayId = ulid();
6726
6929
  const target = { tenantId, scopeId, vertical: rec.vertical };
6727
- const base = { replayId, mode: move.mode, from: move.from, source: source.scopeId };
6930
+ const base = { operationId: replayId, replayId, mode: move.mode, from: move.from, source: source.scopeId };
6728
6931
  await this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, phase: 'intent', reason: move.reason });
6932
+ // #2114: every outcome row goes through the kernel's `recordAuditOutcome`, as the kill
6933
+ // switches' do (#2089). One that cannot be written is logged with the replay id, never
6934
+ // swallowed; the call still answers with the move's own error (or, applied, with the move and
6935
+ // `auditWarning`), and the scheduled settle closes the intent as `unknown`.
6936
+ const recordOutcome = (row) => recordAuditOutcome(() => this.recordAdmin(actor, 'moveImportCursor', target, null, { ...base, ...row }), {
6937
+ flow: 'import-cursor',
6938
+ operationId: replayId,
6939
+ phase: row.phase,
6940
+ }, (message, fields) => console.error(message, fields));
6729
6941
  const at = { move, source: source, replayId };
6730
6942
  let moved;
6731
6943
  try {
@@ -6734,21 +6946,16 @@ export class CloudflareScopeHost {
6734
6946
  : await this.moveInScope(scopeId, at);
6735
6947
  }
6736
6948
  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);
6949
+ await recordOutcome({ phase: 'failed', error: err instanceof Error ? err.message : String(err) });
6742
6950
  throw err;
6743
6951
  }
6744
- await this.recordAdmin(actor, 'moveImportCursor', target, null, {
6745
- ...base,
6952
+ const unrecorded = await recordOutcome({
6746
6953
  phase: 'applied',
6747
6954
  previous: moved.previous,
6748
6955
  cursor: moved.cursor,
6749
6956
  archived: moved.archived,
6750
6957
  });
6751
- return moved;
6958
+ return unrecorded === null ? moved : { ...moved, auditWarning: auditWarningOf('the move', unrecorded) };
6752
6959
  }
6753
6960
  /** #1705 PR 3: the promote gate's question, over two stored manifests (`exportBreaksOf`). */
6754
6961
  exportBreaksBetween(actor, producer, outgoingManifest, incomingManifest) {