@substrat-run/adapter-cloudflare 0.138.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, 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, 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, assertJobRegistrable, isAttachmentTextRun, 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, } from '@substrat-run/kernel';
7
- import { attributedHost } 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';
@@ -275,6 +275,7 @@ export class CloudflareScopeHost {
275
275
  attachmentBuckets;
276
276
  /** The parsers attachment text is extracted with (K-43); the host's own, never imported here. */
277
277
  attachmentExtractors;
278
+ attachmentTextBounds;
278
279
  executors = new Map();
279
280
  /**
280
281
  * `<moduleId>/<job>` → the pass body and its default step policy (#1577). Host
@@ -285,10 +286,10 @@ export class CloudflareScopeHost {
285
286
  */
286
287
  jobs = new Map();
287
288
  /**
288
- * The event currently being effected, stamped onto admin rows the executor writes.
289
- * Ambient rather than threaded through every HostAdmin signature: set and cleared
290
- * around one await, with executors running sequentially, so there is no window
291
- * where it belongs to a different event.
289
+ * The event an executor is effecting, stamped onto the admin rows it writes (K-22). Like
290
+ * `onBehalfOf`, only ever set on a VIEW (`causedByView`, or `attributed` with a
291
+ * `causedBy`), never on the host: a field set around the handler's `await` stamped every
292
+ * admin call the host served meanwhile — a staff call included — with that event (#2055).
292
293
  */
293
294
  causedBy = null;
294
295
  /**
@@ -342,6 +343,7 @@ export class CloudflareScopeHost {
342
343
  this.attachmentBuckets = options.attachmentBuckets ?? ambientAttachmentBucket;
343
344
  assertAttachmentExtractors(options.attachmentExtractors ?? []);
344
345
  this.attachmentExtractors = options.attachmentExtractors ?? [];
346
+ this.attachmentTextBounds = resolveAttachmentTextBounds(options.attachmentTextBounds);
345
347
  this.fetchImpl = options.fetch ?? globalFetch;
346
348
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
347
349
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
@@ -571,7 +573,8 @@ export class CloudflareScopeHost {
571
573
  lines.write({ ...unitOf(event.id, attempt), startedAt, outcome: 'inert' });
572
574
  continue;
573
575
  }
574
- this.causedBy = event.id;
576
+ // #2055: the handler writes through a view bound to its event, never through the host.
577
+ const caused = this.causedByView(event.id);
575
578
  try {
576
579
  if (executor.kind === 'connector' && this.cpLess) {
577
580
  // #574 phase 3: this host cannot run a connector — no connection
@@ -589,14 +592,14 @@ export class CloudflareScopeHost {
589
592
  lines.write({ ...unitOf(event.id, 1), startedAt, outcome: 'routed' });
590
593
  }
591
594
  else if (executor.kind === 'connector') {
592
- await executor.handler(await this.connectorContext(tenantId, scopeId, executor.timeoutMs, event.id), event);
595
+ await executor.handler(await caused.connectorContext(tenantId, scopeId, executor.timeoutMs, event.id), event);
593
596
  const attempt = await stub.recordExecutorAttempt(event.id, deliveryId, null, null, invocationId);
594
597
  report.delivered += 1;
595
598
  outcomeOf(event, 'delivered');
596
599
  lines.write({ ...unitOf(event.id, attempt), startedAt, outcome: 'delivered' });
597
600
  }
598
601
  else {
599
- const result = await executor.handler(this.admin, event, scope);
602
+ const result = await executor.handler(caused.admin, event, scope);
600
603
  // #1184: a refusal is the handler's own terminal decision — journaled with its
601
604
  // reason, no next attempt, listed by `executorDeadLetters` beside an exhausted one.
602
605
  const refused = isDeliveryRefusal(result) ? result : null;
@@ -634,9 +637,6 @@ export class CloudflareScopeHost {
634
637
  error: err,
635
638
  });
636
639
  }
637
- finally {
638
- this.causedBy = null;
639
- }
640
640
  }
641
641
  }
642
642
  }
@@ -701,7 +701,7 @@ export class CloudflareScopeHost {
701
701
  write: (attachmentId, outcome) => stub.attachmentTextRecord(attachmentId, outcome),
702
702
  },
703
703
  // K-43: the host's parsers, handed in — this adapter imports none.
704
- this.attachmentExtractors);
704
+ this.attachmentExtractors, this.attachmentTextBounds);
705
705
  }
706
706
  async startJobRun(tenantId, scopeId, input) {
707
707
  await this.assertLive(tenantId, scopeId);
@@ -713,12 +713,18 @@ export class CloudflareScopeHost {
713
713
  // advance, and an archived one's never move again.
714
714
  await this.assertLive(tenantId, scopeId);
715
715
  await this.migrateAndRecord(scopeId);
716
- // #1575: the kernel's extraction job is this host's own, bound to this scope — no
717
- // deployment registers it, and none can shadow it.
718
- const attachmentText = { handler: this.attachmentTextHandler(tenantId, scopeId) };
716
+ // #1575: the kernel's extraction jobs are this host's own, bound to this scope — no
717
+ // deployment registers them, and none can shadow them. The backfill starts here, on
718
+ // the drive, so a scope's first request never pays for attachments that predate it.
719
+ const stub = this.scopeStub(scopeId);
720
+ await stub.attachmentTextBackfillStart();
721
+ const kernelJobs = {
722
+ [ATTACHMENT_TEXT_JOB]: this.attachmentTextHandler(tenantId, scopeId),
723
+ [ATTACHMENT_TEXT_BACKFILL_JOB]: attachmentTextBackfillJob({ queueBatch: (after) => stub.attachmentTextBackfillBatch(after) }),
724
+ };
719
725
  return runDueJobRuns({
720
726
  store: this.jobStore(scopeId),
721
- handlerFor: (run) => isAttachmentTextRun(run) ? attachmentText : this.jobs.get(`${run.module_id}/${run.job}`),
727
+ handlerFor: (run) => kernelJobFor(run, kernelJobs) ?? this.jobs.get(`${run.module_id}/${run.job}`),
722
728
  now: () => new Date().toISOString(),
723
729
  // #1834: the door is opened FOR this pass, so its "not now" is tied to this pass alone.
724
730
  openScope: async (run, pass) => this.buildStub(tenantId, scopeId, undefined, undefined, await this.openSystemDoor(run.module_id, tenantId, scopeId, undefined, pass)),
@@ -740,13 +746,9 @@ export class CloudflareScopeHost {
740
746
  // ran it. On a CP-less host `connectorContext` throws from the null control plane:
741
747
  // fail closed, exactly the hole routing exists to avoid.
742
748
  await this.assertLive(tenantId, scopeId);
743
- this.causedBy = event.id;
744
- try {
745
- await handler(await this.connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000, event.id), event);
746
- }
747
- finally {
748
- this.causedBy = null;
749
- }
749
+ // Built on a view bound to the event, so its admin rows carry it and nothing else the
750
+ // host serves does (#2055).
751
+ await handler(await this.causedByView(event.id).connectorContext(tenantId, scopeId, options?.timeoutMs ?? 30_000, event.id), event);
750
752
  }
751
753
  async executorDeadLetters(tenantId, scopeId) {
752
754
  await this.validateScopeAccess(tenantId, scopeId);
@@ -775,6 +777,12 @@ export class CloudflareScopeHost {
775
777
  async settlePlatformRequest(tenantId, scopeId, id, outcome) {
776
778
  await this.validateScopeAccess(tenantId, scopeId);
777
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
+ }
778
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));
779
787
  }
780
788
  migrationFrontier() {
@@ -1217,6 +1225,9 @@ export class CloudflareScopeHost {
1217
1225
  if (this.moduleIds.has(manifest.id)) {
1218
1226
  throw new Error(`module already registered: ${manifest.id}`);
1219
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);
1220
1231
  const migrations = registration.migrations ?? [];
1221
1232
  const seen = new Set();
1222
1233
  for (const m of migrations) {
@@ -1239,6 +1250,9 @@ export class CloudflareScopeHost {
1239
1250
  }
1240
1251
  this.predicateNames.set(name, manifest.id);
1241
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);
1242
1256
  // #1705: after the checks above, so a refused module leaves nothing registered here.
1243
1257
  this.crossVertical.register(manifest, registration.imports);
1244
1258
  this.moduleIds.add(manifest.id);
@@ -2167,6 +2181,32 @@ export class CloudflareScopeHost {
2167
2181
  throw unknownRoleError(roleKey);
2168
2182
  return coverage.parse(bound);
2169
2183
  }
2184
+ async listScopeRoleHolders(tenantId, scopeId, principal) {
2185
+ await this.scopeRoleGate(tenantId, scopeId, 'listScopeRoleHolders');
2186
+ return this.scopeStub(scopeId).scopeRoleHoldersFor(scopeId, principal === undefined ? undefined : principalId.parse(principal));
2187
+ }
2188
+ async changeScopeRoleBounded(tenantId, scopeId, caller, principal, from, to) {
2189
+ await this.scopeRoleGate(tenantId, scopeId, 'changeScopeRoleBounded');
2190
+ const target = principalId.parse(principal);
2191
+ const bound = await this.scopeStub(scopeId).changeScopeRoleBoundedFor(tenantId, scopeId, principalId.parse(caller), target, from, to);
2192
+ if (bound === 'not-held')
2193
+ throw substratError('conflict', `${target} does not hold '${from}' at this scope`);
2194
+ if (bound === 'unknown-to')
2195
+ throw unknownRoleError(to);
2196
+ return coverage.parse(bound);
2197
+ }
2198
+ async revokeScopeRolesBounded(tenantId, scopeId, caller, principal) {
2199
+ await this.scopeRoleGate(tenantId, scopeId, 'revokeScopeRolesBounded');
2200
+ const result = await this.scopeStub(scopeId).revokeScopeRolesBoundedFor(tenantId, scopeId, principalId.parse(caller), principalId.parse(principal));
2201
+ return { coverage: coverage.parse(result.coverage), revoked: result.revoked };
2202
+ }
2203
+ /** The (tenant, scope) gate the scope-role verbs share — `assignScopeRoleBounded`'s two checks. */
2204
+ async scopeRoleGate(tenantId, scopeId, verb) {
2205
+ if (this.cpLess && !(await this.scopeStub(scopeId).servesTenant(tenantId))) {
2206
+ throw unknownScopeForTenant(tenantId, scopeId);
2207
+ }
2208
+ await this.peerScopeGate(tenantId, scopeId, verb);
2209
+ }
2170
2210
  /**
2171
2211
  * Where a peer verb (#1706) may reach a scope: its own ScopeDO, after the ordinary pair and
2172
2212
  * lifecycle gate. Refused, loudly, on the SHARED control plane for a scope bound to a
@@ -2422,22 +2462,41 @@ export class CloudflareScopeHost {
2422
2462
  const startedAt = Date.now();
2423
2463
  let emitted;
2424
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;
2425
2467
  try {
2426
2468
  // The gate above already answered for this pass; a fire that meets a restarted scope
2427
2469
  // is gated again by the door (#1834).
2428
2470
  door ??= await this.openSystemDoor(moduleId, tenantId, scopeId, gate);
2429
2471
  const scope = this.buildStub(tenantId, scopeId, undefined, undefined, door);
2430
- 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
+ }
2431
2488
  report.fired += 1;
2432
2489
  }
2433
2490
  catch (err) {
2434
2491
  status = 'failed';
2435
2492
  failure = { error: err };
2436
2493
  report.failed += 1;
2437
- report.errors.push({
2438
- operation: schedule.operation,
2439
- error: err instanceof Error ? err.message : String(err),
2440
- });
2494
+ if (!schedule.purge) {
2495
+ report.errors.push({
2496
+ operation: schedule.operation,
2497
+ error: err instanceof Error ? err.message : String(err),
2498
+ });
2499
+ }
2441
2500
  }
2442
2501
  lines.write({
2443
2502
  kind: 'schedule',
@@ -2452,10 +2511,12 @@ export class CloudflareScopeHost {
2452
2511
  latenessMs: lastRun === null ? null : now - dueAt,
2453
2512
  ...(emitted ? { emitted } : {}),
2454
2513
  });
2514
+ report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
2515
+ if (stillDue)
2516
+ continue;
2455
2517
  // #1288: 'schedule', whatever this operation happens to be called — including
2456
2518
  // `freshness:<something>`, which is exactly the row the evaluator no longer eats.
2457
2519
  await stub.recordScheduleRun(schedule.operation, new Date(now).toISOString(), status, 'schedule', invocationId);
2458
- report.runs.push({ operation: schedule.operation, outcome: status === 'ok' ? 'ok' : 'failed' });
2459
2520
  }
2460
2521
  lines.end();
2461
2522
  return report;
@@ -2520,6 +2581,9 @@ export class CloudflareScopeHost {
2520
2581
  ? 'vertical'
2521
2582
  : undefined,
2522
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);
2523
2587
  // Entitlement gate (§4.3): a module loads for a tenant only if the tenant holds its
2524
2588
  // SKU flag. The COORDINATOR gates the console-managed path against the shared CP
2525
2589
  // (`cp.tenantHoldsEntitlement`); for a hosted/CP-less scope that call is a trusting
@@ -2651,8 +2715,12 @@ export class CloudflareScopeHost {
2651
2715
  }
2652
2716
  // -- admin surface --------------------------------------------------------
2653
2717
  /** #977: this host, with every admin row it writes naming who the actor acted for. */
2654
- attributed(onBehalfOf) {
2655
- return attributedHost(this, onBehalfOf, this.buildAdmin);
2718
+ attributed(onBehalfOf, options) {
2719
+ return attributedView(this, { onBehalfOf, causedBy: options?.causedBy }, this.buildAdmin);
2720
+ }
2721
+ /** #2055: this host, with every admin row it writes naming `eventId` as its cause. */
2722
+ causedByView(eventId) {
2723
+ return attributedView(this, { causedBy: eventId }, this.buildAdmin);
2656
2724
  }
2657
2725
  buildAdmin() {
2658
2726
  // The directory row → the `scope` contract. Parsed, not cast: the columns are
@@ -2918,11 +2986,21 @@ export class CloudflareScopeHost {
2918
2986
  // admin log are separate, so no order makes the pair atomic; this one fails toward
2919
2987
  // "an intent with no recorded outcome" and never toward "a switch that moved with no
2920
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`.
2921
2994
  const operationId = ulid();
2922
2995
  const target = { tenantId, scopeId, vertical };
2923
2996
  const base = { operationId, ...switchAuditSubject(kind, key, to) };
2924
2997
  await this.recordAdmin(actor, action, target, null, { ...base, phase: 'intent', reason });
2925
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));
2926
3004
  // #2045 (Codex r3): the deployment's fence is attested BEFORE anything is recorded or moved. An
2927
3005
  // ON on a deployment built before it is refused here, with nothing written anywhere but the
2928
3006
  // audit. An OFF goes on: the kill switch must work on every deployment (see `UnattestedSwitch`).
@@ -2932,7 +3010,7 @@ export class CloudflareScopeHost {
2932
3010
  }
2933
3011
  }
2934
3012
  catch (err) {
2935
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', error: errorOf(err) }).catch(() => undefined);
3013
+ await recordOutcome({ phase: 'refused', error: errorOf(err) });
2936
3014
  throw err;
2937
3015
  }
2938
3016
  // The directory's record (#1674; #2029 for a peer), written BEFORE the scope moves, both
@@ -2950,13 +3028,13 @@ export class CloudflareScopeHost {
2950
3028
  // Nothing has moved: fail the call here, audited. An ON must not switch a scope on
2951
3029
  // whose record still says off (the next reconcile would switch it back off), and an
2952
3030
  // OFF must not leave a scope off that the record does not know of (#1823).
2953
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'failed', error: errorOf(err) }).catch(() => undefined);
3031
+ await recordOutcome({ phase: 'failed', error: errorOf(err) });
2954
3032
  throw err;
2955
3033
  }
2956
3034
  // #2045: a newer call on this subject has recorded its position already: this one writes
2957
3035
  // nothing, here or in the scope, and says so.
2958
3036
  if (recordWriteSuperseded(prior, record)) {
2959
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
3037
+ await recordOutcome({ phase: 'refused', superseded: true });
2960
3038
  throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
2961
3039
  }
2962
3040
  let outcome;
@@ -2978,13 +3056,12 @@ export class CloudflareScopeHost {
2978
3056
  // readback can settle the scope and nothing is put back: the record is this call's position
2979
3057
  // under the newest fence, and its write-ahead mark makes the next re-assert move the scope
2980
3058
  // to it, under that fence, whichever way it lies now.
2981
- await this.recordAdmin(actor, action, target, null, {
2982
- ...base,
3059
+ await recordOutcome({
2983
3060
  phase: err instanceof UnattestedSwitch ? 'refused' : 'failed',
2984
3061
  error: errorOf(err),
2985
3062
  recordKept: true,
2986
3063
  reassertOwed: true,
2987
- }).catch(() => undefined);
3064
+ });
2988
3065
  if (err instanceof UnattestedSwitch)
2989
3066
  throw substratError('precondition_failed', err.message);
2990
3067
  throw err;
@@ -2993,7 +3070,7 @@ export class CloudflareScopeHost {
2993
3070
  // record is that newer call's too (its write overwrote this one's, mark included), so nothing
2994
3071
  // is undone and no mark is this call's to clear.
2995
3072
  if (outcome.superseded) {
2996
- await this.recordAdmin(actor, action, target, null, { ...base, phase: 'refused', superseded: true });
3073
+ await recordOutcome({ phase: 'refused', superseded: true });
2997
3074
  throw substratError('conflict', switchSupersededMessage(kind, scopeId, key, to));
2998
3075
  }
2999
3076
  // A call that held nothing moved nothing, so its record write is undone: left `off`, it
@@ -3020,8 +3097,7 @@ export class CloudflareScopeHost {
3020
3097
  else if (!unfenced) {
3021
3098
  owedError = await this.cp.clearSwitchOwed(kind, tenantId, scopeId, key, operationId).then(() => null, errorOf);
3022
3099
  }
3023
- await this.recordAdmin(actor, action, target, null, {
3024
- ...base,
3100
+ const unrecorded = await recordOutcome({
3025
3101
  phase: outcome.held ? 'applied' : 'refused',
3026
3102
  changed: outcome.changed,
3027
3103
  permissions: outcome.permissions,
@@ -3033,30 +3109,32 @@ export class CloudflareScopeHost {
3033
3109
  throw substratError('not_found', switchNotFoundMessage(kind, scopeId, key, to) +
3034
3110
  (recordError ? `; and its directory record could not be put back (${recordError})` : ''));
3035
3111
  }
3036
- return { operationId, outcome };
3112
+ return { operationId, outcome, ...(unrecorded !== null ? { auditWarning: auditWarningOf('the switch', unrecorded) } : {}) };
3037
3113
  };
3038
3114
  /** #1666: move one module's schedule switch on one scope — see `HostAdmin.revokeFromSystem`. */
3039
3115
  const switchSystem = async (actor, raw, to) => {
3040
3116
  const input = systemSwitch.parse(raw);
3041
- 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);
3042
3118
  return {
3043
3119
  operationId,
3044
3120
  moduleId: input.moduleId,
3045
3121
  schedules: to,
3046
3122
  changed: outcome.changed,
3047
3123
  permissions: outcome.permissions,
3124
+ ...(auditWarning ? { auditWarning } : {}),
3048
3125
  };
3049
3126
  };
3050
3127
  /** #1706: move one PEER's kill switch on one scope — see `HostAdmin.revokeFromPeer`. */
3051
3128
  const switchPeerAt = async (actor, raw, to) => {
3052
3129
  const input = peerSwitch.parse(raw);
3053
- 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);
3054
3131
  return {
3055
3132
  operationId,
3056
3133
  vertical: input.vertical,
3057
3134
  calls: to,
3058
3135
  changed: outcome.changed,
3059
3136
  permissions: outcome.permissions,
3137
+ ...(auditWarning ? { auditWarning } : {}),
3060
3138
  };
3061
3139
  };
3062
3140
  /**
@@ -3323,6 +3401,8 @@ export class CloudflareScopeHost {
3323
3401
  return result;
3324
3402
  };
3325
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,
3326
3406
  // #603: fixed at construction — a worker deployed without SECRET_BOX_KEY can never
3327
3407
  // store a credential, and saying so is what lets a transport answer 503 instead of 500.
3328
3408
  canStoreSecrets: isSecretBoxConfigured(this.secretBox),
@@ -3395,6 +3475,17 @@ export class CloudflareScopeHost {
3395
3475
  if (!grant.node.scopeId)
3396
3476
  await this.fanOut(grant.node.tenantId);
3397
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
+ },
3398
3489
  grantToConnection: async (actor, raw) => {
3399
3490
  const grant = connectionGrant.parse(raw);
3400
3491
  const conn = await this.cp.readConnection(grant.connectionId);
@@ -3651,6 +3742,23 @@ export class CloudflareScopeHost {
3651
3742
  // #1706: the directory's answer to "vertical Y in tenant T", in the ControlPlaneDO, by
3652
3743
  // the kernel's one rule. No actor, not logged — `resolveHostname`'s machine-path reason.
3653
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
+ },
3654
3762
  resolveHostname: async (raw) =>
3655
3763
  // The router's per-request read. No actor, not logged — the same machine-path
3656
3764
  // carve-out resolveIdentity has (K-24). Shares its mapping with the router's
@@ -3676,6 +3784,9 @@ export class CloudflareScopeHost {
3676
3784
  // Declared email-sender intent (#303) — the request half; the grant is its own column.
3677
3785
  if (parsed.sendsEmail)
3678
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;
3679
3790
  const installSpecJson = Object.keys(installSpec).length ? JSON.stringify(installSpec) : null;
3680
3791
  const existing = await this.cp.readVertical(parsed.slug);
3681
3792
  if (existing) {
@@ -4156,21 +4267,25 @@ export class CloudflareScopeHost {
4156
4267
  await this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, r ? 1 : 0);
4157
4268
  return r ? mapOrg(r) : undefined;
4158
4269
  },
4159
- addMember: async (actor, tenantId, principal, orgId) => {
4270
+ addMember: async (actor, tenantId, principal, orgId, opts) => {
4160
4271
  await requireOrg(tenantId, orgId);
4161
- await this.cp.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, null);
4162
- await this.recordAdmin(actor, 'addMember', { tenantId }, null, { principal, orgId });
4272
+ const expiresAt = opts?.expiresAt ?? null;
4273
+ await this.cp.writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`, expiresAt);
4274
+ await this.recordAdmin(actor, 'addMember', { tenantId }, null, memberAddedAudit(principal, orgId, expiresAt));
4163
4275
  await this.fanOut(tenantId); // membership is a tenant-level tuple
4164
4276
  },
4165
4277
  applyMembership: async (actor, change) => {
4166
4278
  // The row is minted here, where attribution and `causedBy` live, and written by the
4167
4279
  // ControlPlaneDO in the same synchronous method as the fence, the bound and the tuple
4168
4280
  // (#1184): one DO unit, so nothing lands between the check and the write.
4169
- const { tenantId, principal, roleKey, op } = change;
4281
+ const { tenantId, principal, roleKey, op, orgId } = change;
4170
4282
  const assignment = { principalId: principal, roleKey, node: { tenantId, scopeId: null } };
4171
4283
  const result = await this.cp.applyMembership(change, op === 'add'
4172
4284
  ? this.adminEntry(actor, 'assignRole', { tenantId, scopeId: null }, null, assignment)
4173
- : this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null));
4285
+ : this.adminEntry(actor, 'unassignRole', { tenantId, scopeId: null }, assignment, null),
4286
+ // #2047: the org's own row, when the change joins or leaves one. The unit fills in its
4287
+ // `before`/`after` and writes it only for what it does — a join's carries the expiry.
4288
+ orgId ? this.adminEntry(actor, op === 'add' ? 'addMember' : 'removeMember', { tenantId }, null, null) : undefined);
4174
4289
  // The tenant-level tuple, or its tombstone, reaches the projections.
4175
4290
  if (result.applied && (op === 'add' || result.changed))
4176
4291
  await this.fanOut(tenantId);
@@ -4192,6 +4307,7 @@ export class CloudflareScopeHost {
4192
4307
  principal: r.subject.slice('principal:'.length),
4193
4308
  orgId,
4194
4309
  revokedAt: r.revoked_at,
4310
+ expiresAt: r.expires_at,
4195
4311
  }));
4196
4312
  },
4197
4313
  createTenant: async (actor, input) => {
@@ -4673,6 +4789,12 @@ export class CloudflareScopeHost {
4673
4789
  // Both spine copies (#1600): the outbox row AND any platform intent this event was
4674
4790
  // routed into. One RPC, so a crash cannot land half of it.
4675
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
+ }
4676
4798
  // An OLD ScopeDO answers with a bare number — it redacted the outbox and never
4677
4799
  // looked at the intent journal. Refused here, BEFORE the key is destroyed, and
4678
4800
  // that order is the whole point: the key is the irreversible half, so proceeding
@@ -4705,7 +4827,16 @@ export class CloudflareScopeHost {
4705
4827
  `or a queued sweep-runs intent — erasing now would destroy the subject key while ` +
4706
4828
  `leaving their data in those rows. Redeploy the vertical and re-run.`);
4707
4829
  }
4708
- 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;
4709
4840
  // The directory's failure text (#1632) — a drain failure quoting one of those intents,
4710
4841
  // an issue's exemplar, a sweep record's error. Still before the key.
4711
4842
  await this.cp.redactSubjectText({ tenantId, scopeId, subjectId, intentIds });
@@ -4716,6 +4847,7 @@ export class CloudflareScopeHost {
4716
4847
  eventsRedacted,
4717
4848
  intentsRedacted,
4718
4849
  jobRunsRedacted,
4850
+ ...vertical,
4719
4851
  keyDestroyed: existed,
4720
4852
  tombstoned: true,
4721
4853
  });
@@ -4725,7 +4857,7 @@ export class CloudflareScopeHost {
4725
4857
  await this.recordAdmin(actor, 'shredSubject', { tenantId, scopeId }, null, receipt);
4726
4858
  // BOTH counts: the access log's number is "how much evidence this destroyed", and
4727
4859
  // an intent payload is a whole event's worth of it.
4728
- 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));
4729
4861
  return receipt;
4730
4862
  },
4731
4863
  // -- impersonation (K-42, #868) ----------------------------------------
@@ -4968,12 +5100,12 @@ export class CloudflareScopeHost {
4968
5100
  throw new Error(`connection not found: ${id}`);
4969
5101
  const sealed = await this.secretBox.seal(JSON.stringify(connectionSecret.parse(secret)));
4970
5102
  const now = new Date().toISOString();
4971
- 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);
4972
5104
  await this.recordAdmin(actor, 'updateConnectionSecret', { tenantId: row.tenant_id, vertical: row.vertical }, null, {
4973
5105
  id,
4974
5106
  provider: row.provider,
4975
5107
  rotatedAt: now,
4976
- expiresAt: expiresAt ?? row.expires_at,
5108
+ expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
4977
5109
  // §3.5.1's attribution, rotate-side: the authorizing tenant principal,
4978
5110
  // never laundered into the actor column.
4979
5111
  ...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
@@ -4989,6 +5121,55 @@ export class CloudflareScopeHost {
4989
5121
  return; // idempotent, and a no-op is not audited
4990
5122
  await this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
4991
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
+ },
4992
5173
  openConnection: async (tenantId, vertical, provider, externalAccountRef) => {
4993
5174
  const row = await this.cp.readLiveConnection(tenantId, vertical, provider, externalAccountRef);
4994
5175
  if (!row)
@@ -5085,6 +5266,23 @@ export class CloudflareScopeHost {
5085
5266
  const { tenantId, scopeId, ...after } = ownerTransferAudit.parse(entry);
5086
5267
  await this.recordAdmin(actor, 'transferOwner', { tenantId, scopeId }, null, after);
5087
5268
  },
5269
+ /** #1150: one phase of a dashboard member change, written around the vertical's own. */
5270
+ recordMemberChange: async (actor, entry) => {
5271
+ const { tenantId, scopeId, ...after } = memberChangeAudit.parse(entry);
5272
+ await this.recordAdmin(actor, 'manageScopeMember', { tenantId, scopeId }, null, after);
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 }),
5088
5286
  /** #2005: one change to a scope's copy marker, written around the vertical's own change. */
5089
5287
  recordCopyMark: async (actor, entry) => {
5090
5288
  const { tenantId, scopeId, action, ...after } = copyMarkAudit.parse(entry);
@@ -5241,6 +5439,8 @@ export class CloudflareScopeHost {
5241
5439
  },
5242
5440
  // Checked here as well as in the directory, so the refusal keeps its code across the hop.
5243
5441
  pruneTelemetry: async (_actor, limit) => this.cp.pruneTelemetry(assertRowLimit('limit', limit)),
5442
+ // #1748: the DO writes each stale resolution's audit row in the same unit, from this one.
5443
+ pruneFindings: async (actor, limit) => this.cp.pruneFindings(assertRowLimit('limit', limit), this.adminEntry(actor, 'resolveStaleFinding', { tenantId: null }, null, null)),
5244
5444
  listIssues: async (actor, filter) => {
5245
5445
  const rows = await this.cp.listIssues({
5246
5446
  status: filter?.status,
@@ -5262,6 +5462,33 @@ export class CloudflareScopeHost {
5262
5462
  await this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: before.status }, { fingerprint, status: after.status });
5263
5463
  return after;
5264
5464
  },
5465
+ listFindings: async (actor, filter) => {
5466
+ const rows = await this.cp.listFindings({ ...filter });
5467
+ await this.recordAccess(actor, 'listFindings', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
5468
+ return rows.map((r) => findingEntry.parse(r));
5469
+ },
5470
+ // #1748: each findings mutation writes its audit row in the DO's own unit, from the row
5471
+ // minted here (actor, attribution) — so a failed audit write rolls the mutation back.
5472
+ setFindingStatus: async (actor, tenantId, id, status) => {
5473
+ const audit = this.adminEntry(actor, 'setFindingStatus', { tenantId }, null, null);
5474
+ const change = await this.cp.setFindingStatus(tenantId, id, status, new Date().toISOString(), audit);
5475
+ return change ? findingEntry.parse(change.after) : undefined;
5476
+ },
5477
+ createFindingRule: async (actor, tenantId, input) => {
5478
+ const audit = this.adminEntry(actor, 'createFindingRule', { tenantId }, null, null);
5479
+ const created = await this.directory('createFindingRule', tenantId, input, actor, new Date().toISOString(), audit);
5480
+ return { rule: findingRuleEntry.parse(created.rule), suppressed: created.suppressed.length };
5481
+ },
5482
+ revokeFindingRule: async (actor, tenantId, ruleId) => {
5483
+ const audit = this.adminEntry(actor, 'revokeFindingRule', { tenantId }, null, null);
5484
+ const change = await this.cp.revokeFindingRule(tenantId, ruleId, new Date().toISOString(), audit);
5485
+ return change ? findingRuleEntry.parse(change.after) : undefined;
5486
+ },
5487
+ listFindingRules: async (actor, tenantId, filter) => {
5488
+ const rows = await this.cp.listFindingRules(tenantId, filter?.active ? new Date().toISOString() : undefined, filter?.limit);
5489
+ await this.recordAccess(actor, 'listFindingRules', { tenantId }, filter, rows.length);
5490
+ return rows.map((r) => findingRuleEntry.parse(r));
5491
+ },
5265
5492
  recordModelUsage: async (input) => {
5266
5493
  const l = input.line;
5267
5494
  return this.cp.recordModelUsage({
@@ -5653,7 +5880,7 @@ export class CloudflareScopeHost {
5653
5880
  }));
5654
5881
  }
5655
5882
  async writeScopeTuple(scopeId, subject, relation, object, expiresAt) {
5656
- await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt);
5883
+ unwrapReply(await this.scopeStub(scopeId).writeTuple(subject, relation, object, expiresAt));
5657
5884
  }
5658
5885
  /**
5659
5886
  * The connection's CURRENT public sealing key, minted on first ask (#687).
@@ -6044,11 +6271,17 @@ export class CloudflareScopeHost {
6044
6271
  * arrive as a different class of answer just because the caller asked for a socket.
6045
6272
  */
6046
6273
  liveReads = {
6047
- subscribe: async ({ tenantId, scopeId, principal, request, within }) => {
6274
+ subscribe: async ({ tenantId, scopeId, principal, request, within, expiresAt }) => {
6048
6275
  // Decided before anything else, and thrown: a `within` that is neither a plain
6049
6276
  // EntityRef nor a value `vouchedWithin` built is a caller bug, and the two ways of
6050
6277
  // guessing at it both open a feed wider than was asked for (#1853).
6051
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
+ }
6052
6285
  if (!isUpgradeRequest(request)) {
6053
6286
  return new Response('live reads are a WebSocket surface', {
6054
6287
  status: 426,
@@ -6110,6 +6343,10 @@ export class CloudflareScopeHost {
6110
6343
  headers.set(LIVE_WITHIN_HEADER, encodeLiveWithin(narrowed));
6111
6344
  else
6112
6345
  headers.delete(LIVE_WITHIN_HEADER);
6346
+ if (expires)
6347
+ headers.set(LIVE_EXPIRES_HEADER, expires);
6348
+ else
6349
+ headers.delete(LIVE_EXPIRES_HEADER);
6113
6350
  const forwarded = new Request(new URL(LIVE_SUBSCRIBE_PATH, 'https://scope.substrat.internal'),
6114
6351
  // `new Request(url, { …, headers })` rather than `new Request(request, …)`:
6115
6352
  // the DO is addressed by its own path, not the client's, and the upgrade
@@ -6367,6 +6604,8 @@ export class CloudflareScopeHost {
6367
6604
  // #1742: the recorded-off modules and (#2029) peers, switched off after the seat in the
6368
6605
  // same unit, on the scope this call provisions — the one the seat's tuples name.
6369
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);
6370
6609
  return carry ? { switchedOff } : {};
6371
6610
  }
6372
6611
  /**
@@ -6392,7 +6631,7 @@ export class CloudflareScopeHost {
6392
6631
  * already projected (via `provisionScopeLocal`), or the local checker expands it to nothing.
6393
6632
  */
6394
6633
  async assignScopeRole(scopeId, principal, roleKey) {
6395
- 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));
6396
6635
  }
6397
6636
  /**
6398
6637
  * Take a scope-level role back — the counterpart `assignScopeRole` went without (#1161).
@@ -6451,8 +6690,41 @@ export class CloudflareScopeHost {
6451
6690
  * (writeTuple is INSERT OR REPLACE), so it is safe to re-issue on every link.
6452
6691
  */
6453
6692
  async grantEntityLocal(scopeId, principal, permission, entity) {
6454
- await this.scopeStub(scopeId).writeTuple(`principal:${principal}`, `granted:${permission}`, entityObjectRef(entity, 'grantEntityLocal'), // #1856
6455
- 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 };
6456
6728
  }
6457
6729
  // -- the connector write-back's far end (#574) -----------------------------
6458
6730
  // A CP-less dispatch vertical cannot run a connector, so the shared control
@@ -6651,10 +6923,21 @@ export class CloudflareScopeHost {
6651
6923
  if (!delegation)
6652
6924
  this.assertServedHere(rec, scopeId, 'moveImportCursor');
6653
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'.
6654
6928
  const replayId = ulid();
6655
6929
  const target = { tenantId, scopeId, vertical: rec.vertical };
6656
- 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 };
6657
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));
6658
6941
  const at = { move, source: source, replayId };
6659
6942
  let moved;
6660
6943
  try {
@@ -6663,21 +6946,16 @@ export class CloudflareScopeHost {
6663
6946
  : await this.moveInScope(scopeId, at);
6664
6947
  }
6665
6948
  catch (err) {
6666
- await this.recordAdmin(actor, 'moveImportCursor', target, null, {
6667
- ...base,
6668
- phase: 'failed',
6669
- error: err instanceof Error ? err.message : String(err),
6670
- }).catch(() => undefined);
6949
+ await recordOutcome({ phase: 'failed', error: err instanceof Error ? err.message : String(err) });
6671
6950
  throw err;
6672
6951
  }
6673
- await this.recordAdmin(actor, 'moveImportCursor', target, null, {
6674
- ...base,
6952
+ const unrecorded = await recordOutcome({
6675
6953
  phase: 'applied',
6676
6954
  previous: moved.previous,
6677
6955
  cursor: moved.cursor,
6678
6956
  archived: moved.archived,
6679
6957
  });
6680
- return moved;
6958
+ return unrecorded === null ? moved : { ...moved, auditWarning: auditWarningOf('the move', unrecorded) };
6681
6959
  }
6682
6960
  /** #1705 PR 3: the promote gate's question, over two stored manifests (`exportBreaksOf`). */
6683
6961
  exportBreaksBetween(actor, producer, outgoingManifest, incomingManifest) {