@substrat-run/adapter-cloudflare 0.163.0 → 0.165.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/host.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { env as ambientEnv } from 'cloudflare:workers';
2
2
  import { isRewindRefusal, REWIND_REFUSED } from './rewind-refusal.js';
3
3
  import { isSystemDoorMoved, SYSTEM_DOOR_MOVED, SYSTEM_DOOR_REGATES } from './system-door.js';
4
- import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, errorCodeOf, redrainEventsInput, coverage, peerCoverage, peerGrantsEntry, peerSwitch, peerSwitchOutcome, verticalCaller, verticalResolution, peerInstanceResolution, peerBinding as peerBindingSchema, verticalSlug, entityObjectRef, ATTACHMENT_BLOB_BINDING, blobStoreBindingName, lifecycleDelivery, scopeLifecycle, } from '@substrat-run/contracts';
4
+ import { delegatedReadParams, operationSeriesCount, fromWireFailure, isSubstratError, exportReadInput, exportedBatch, importBatch, importResult, importState, importsOfManifestJson, connectionLandingsOfManifestJson, connectorsOfManifestJson, exportsOfManifestJson, assertNoBindingShapeBreak, importCursorMove, importCursorMoveAt, importCursorMoved, accessLogEntry, adminLogEntry, opsFailureEntry, opsFailureFingerprint, issueEntry, findingEntry, findingRuleEntry, sweepRunEntry, FRESHNESS_HEARTBEAT_MINUTES, sweepRunsPayload, modelUsageEntry, createTenantInput, identityLink, identityPool, createOrgInput, promotionAcknowledgement, bindAcknowledgement, bindHostnameInput, channelHistoryEntry, hostnameBinding, publishVersionInput, AUTO_ADMISSION_NOTE, registerVerticalInput, vertical as verticalSchema, verticalServingState, deploymentWireAuth, verticalChannel, verticalVersion, connection, capabilityExchange, mintedCapability, boundedBecomeMint, boundedBecomeRevoke, boundedUnbind, entityRef, becomeLinkState, principalBecomeCapabilityInput, capabilityGrant, principalId, connectionGrant, connectionGrantRecord, connectionSecret, systemGrant, systemSwitch, systemSwitchOutcome, systemScheduleEntry, systemSwitchRecord, entitlementGrant, entitlementGrantInput, capabilityFilter, instant, meterReading, scopeStorageSample, requestDay, requestRollupDay, meterDay, meterDayTake, verticalOwnerPeriod, subjectRef, createConnectionInput, connectLinkFilter, connectLinkKey, consumeConnectLinkInput, mintConnectLinkInput, projectedConnectionGrant, projectedConnectionKey, moduleManifest, org as orgSchema, orgMembership, resolvedIdentity, identityMembership, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, delegatedReadRecord, ownerTransferAudit, memberChangeAudit, peerBindingAudit, connectionBindingAudit, copyMarkAudit, orphanSweepAudit, subjectShredReceipt, connectorDispatchKind, callsOfManifestJson, outboundOfManifestJson, SCOPE_GATE_REASONS, substratError, errorCodeOf, 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, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, bytesStream, isSizedByteStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, fetchLikeOf, deadlineFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleRefusal, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
6
+ import { attachmentBlobKey, normalizeUploadMediaType, redeemOrReplayUploadLink, attachmentSha256, bytesStream, isSizedByteStream, openAttachmentByteStream, putAttachmentBytes, entitlementDenial, requiredEntitlementFor, foldMeterReading, parseValidationRecords, readableScopes, resolveScopeRecord, ulid, withheldOpsFailure, KEPT_COPY_REFUSAL, capabilityTokenHash, checkBecomeInput, assertBecomeLinkStateIds, plausibleSessionToken, foldModelUsage, backoffAt, resolveRetryPolicy, executorOutcomeOf, isDeliveryRefusal, refusalJournalText, isSecretBoxConfigured, unconfiguredSecretBox, createSubjectKeys, generateSealingKeyPair, openSealed, ImpersonationRefused, assertSessionUsable, impersonationRowValues, mapImpersonationRow, newImpersonationSession, jobRunOf, assertLeaseMs, runDueJobRuns, SYSTEM_DOOR_WAIT, startJobRun, attachmentTextJob, assertAttachmentExtractors, resolveAttachmentTextBounds, assertJobRegistrable, ATTACHMENT_TEXT_BACKFILL_JOB, ATTACHMENT_TEXT_JOB, kernelJobFor, attachmentTextBackfillJob, searchLimit, searchMatchExpression, globalFetch, fetchLikeOf, deadlineFetch, assertRedrainWindow, platformRequestOf, undrainedEventsOf, inUnitMovesToAudit, staleCarryRevertRow, staleCarryReverts, recordedOffFromWire, peerSubjectRef, SWITCH_KINDS, reassertActionOf, reassertEntry, reassertOffRow, reassertOnRow, switchActionOf, switchAuditSubject, recordAuditOutcome, auditedCapabilityRevoke, auditWarningOf, switchNotFoundMessage, recordWriteSuperseded, switchSubjectOf, switchSupersededMessage, withRecorded, systemSwitchedOffMessage, tenantSystemSwitchedOffMessage, CrossVerticalRegistry, importCursorSourceOf, exportBreaksOf, exportBreakRefusal, assertPromoteReviewCurrent, promoteRaceRefusal, servingRaceRefusal, migratesFrom, servedFromRow, rebindRaceRefusal, bindExportBreaksOf, bindExportBreakRefusal, scopeMoveOf, runningVersionOf, strandJudgmentsOf, boundShapesOfColumn, installReconciled, collectPeers, peerCallerAt, peerSeats, connectorCallRecord, noopConnectorCallRecorder, recordConnectorCall, settleConnectionUse, splitManifestMigrations, publishedMigrationDigest, migrationChangeRefusal, unknownRoleError, assertRowLimit, assertRowOffset, emptyImportResult, INERT_SCOPE_REASON, lifecycleReceipt, lifecycleRefusal, scopeNotHeldRefusal, isPrimaryScope, isPrimaryScopeRow, asyncInvocationId, asyncLinePass, assertNoCallerPurge, purgeReportOf, purgeStillDue, assertBoundOperations, registerTrashTargets, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, reservedGrantRefusal, reservedRoleRefusal, reservedRoleSetRefusal, reservedShapeRefusal, SEAT_HOLDING_SCOPE_STATUSES, dumpRoleRefusal, dumpRoleState, memberAddedAudit, shapeTopUpBatch, verticalOwnerNow, } from '@substrat-run/kernel';
7
7
  import { fenceAttachmentSweep, missingAttachmentStore, sweepOrphanAttachments, unsettledCopyRefusal } from '@substrat-run/kernel';
8
8
  import { assertWireAuthFilter, wireAuthAccessTarget, COPY_RESTORE_FENCE_LAPSED, scopeScriptCopyOf } from '@substrat-run/kernel';
9
9
  import { attributedView, isModuleErasureCounts, moduleErasurePlan, moduleRowsErased, callReservedStoreOperation, callScopeStore, observeStoreCall, replayStoreErasures, scopeStoreLifecycleOf, scopeStoreRunnersOf } from '@substrat-run/kernel';
@@ -165,9 +165,11 @@ export const SWITCH_HOLD_SNAPSHOT_MS = 2_000;
165
165
  * one older than `SWITCH_HOLD_SNAPSHOT_MS`. A state read that met rewound storage therefore
166
166
  * finished at least this long after the hold was written, so a snapshot taken before the hold
167
167
  * is past its age by then and is re-read. Each side measures a duration on its own clock;
168
- * nothing compares two clocks.
168
+ * nothing compares two clocks. Derived from the snapshot age, never set on its own, so no host
169
+ * can be given a settle that does not outlast its snapshot (`switchHoldSnapshotMs`).
169
170
  */
170
- export const SWITCH_HOLD_SETTLE_MS = SWITCH_HOLD_SNAPSHOT_MS + 1_000;
171
+ export const switchHoldSettleMs = (snapshotMs) => (snapshotMs * 3) / 2;
172
+ export const SWITCH_HOLD_SETTLE_MS = switchHoldSettleMs(SWITCH_HOLD_SNAPSHOT_MS);
171
173
  /**
172
174
  * #1839: how many times a rewind waits again, after its settle, for a row a late OFF added to its
173
175
  * claim to be `SWITCH_HOLD_SETTLE_MS` old. The settle argument above holds per ROW, so a row
@@ -479,6 +481,9 @@ export class CloudflareScopeHost {
479
481
  * join this one read rather than each sending their own.
480
482
  */
481
483
  holdRefresh = null;
484
+ /** #1819: `SWITCH_HOLD_SNAPSHOT_MS` and `SWITCH_HOLD_SETTLE_MS`, unless `switchHoldSnapshotMs` is given. */
485
+ holdSnapshotMs;
486
+ holdSettleMs;
482
487
  /**
483
488
  * MUST be constructed per request. Never cache an instance across requests.
484
489
  *
@@ -509,6 +514,12 @@ export class CloudflareScopeHost {
509
514
  // #2498: whatever fetch is injected, a sized stream body reaches it converted (`fetchLikeOf`).
510
515
  this.fetchImpl = fetchLikeOf(options.fetch ?? globalFetch);
511
516
  this.streamIdleMs = options.streamIdleTimeoutMs;
517
+ this.holdSnapshotMs = options.switchHoldSnapshotMs ?? SWITCH_HOLD_SNAPSHOT_MS;
518
+ // NaN or Infinity never expires a snapshot; zero or less settles for nothing.
519
+ if (!Number.isFinite(this.holdSnapshotMs) || this.holdSnapshotMs <= 0) {
520
+ throw new RangeError(`switchHoldSnapshotMs must be a positive, finite number of milliseconds, got ${this.holdSnapshotMs}`);
521
+ }
522
+ this.holdSettleMs = switchHoldSettleMs(this.holdSnapshotMs);
512
523
  this.connectorCalls = options.connectorCalls ?? noopConnectorCallRecorder;
513
524
  this.scopeLocalPermissions = options.scopeLocalPermissions ?? false;
514
525
  this.scopeNs = options.scope;
@@ -1853,14 +1864,14 @@ export class CloudflareScopeHost {
1853
1864
  // the source of truth, carrying the D1 database_id Cloudflare assigned as the ref.
1854
1865
  const name = await tenantStoreDatabaseName(input.tenantId, input.vertical, input.binding);
1855
1866
  const ref = await d1.create(name);
1856
- const stored = await this.directory('putTenantStore', {
1867
+ const stored = await this.directory('putTenantStoreAndAudit', {
1857
1868
  tenantId: input.tenantId,
1858
1869
  vertical: input.vertical,
1859
1870
  binding: input.binding,
1860
1871
  kind: 'relational',
1861
1872
  ref,
1862
1873
  createdAt: new Date().toISOString(),
1863
- });
1874
+ }, this.adminEntry(actor, 'provisionTenantStore', { tenantId: input.tenantId, vertical: input.vertical }, null, { binding: input.binding, kind: 'relational', ref }));
1864
1875
  if (stored.ref !== ref) {
1865
1876
  // A concurrent provision won the ledger write. One database is canonical — the
1866
1877
  // ledger's — so drop ours rather than orphan it (best-effort: a leaked delete
@@ -1868,7 +1879,6 @@ export class CloudflareScopeHost {
1868
1879
  await d1.remove(ref).catch(() => undefined);
1869
1880
  return { binding: input.binding, kind: 'relational', ref: stored.ref };
1870
1881
  }
1871
- await this.recordAdmin(actor, 'provisionTenantStore', { tenantId: input.tenantId, vertical: input.vertical }, null, { binding: input.binding, kind: 'relational', ref });
1872
1882
  return { binding: input.binding, kind: 'relational', ref };
1873
1883
  }
1874
1884
  openTenantStore(handle) {
@@ -1914,19 +1924,18 @@ export class CloudflareScopeHost {
1914
1924
  return { binding: input.binding, kind: 'blob', ref: existing.ref };
1915
1925
  const name = await blobStoreBucketName(input.tenantId, input.vertical, input.binding);
1916
1926
  const ref = await r2.create(name);
1917
- const stored = await this.directory('putBlobStore', {
1927
+ const stored = await this.directory('putBlobStoreAndAudit', {
1918
1928
  tenantId: input.tenantId,
1919
1929
  vertical: input.vertical,
1920
1930
  binding: input.binding,
1921
1931
  kind: 'blob',
1922
1932
  ref,
1923
1933
  createdAt: new Date().toISOString(),
1924
- });
1934
+ }, this.adminEntry(actor, 'provisionBlobStore', { tenantId: input.tenantId, vertical: input.vertical }, null, { binding: input.binding, kind: 'blob', ref }));
1925
1935
  if (stored.ref !== ref) {
1926
1936
  await r2.remove(ref).catch(() => undefined);
1927
1937
  return { binding: input.binding, kind: 'blob', ref: stored.ref };
1928
1938
  }
1929
- await this.recordAdmin(actor, 'provisionBlobStore', { tenantId: input.tenantId, vertical: input.vertical }, null, { binding: input.binding, kind: 'blob', ref });
1930
1939
  return { binding: input.binding, kind: 'blob', ref };
1931
1940
  }
1932
1941
  /**
@@ -4792,6 +4801,9 @@ export class CloudflareScopeHost {
4792
4801
  installSpec.provides = parsed.provides;
4793
4802
  if (parsed.requires)
4794
4803
  installSpec.requires = parsed.requires;
4804
+ // The declared vertical-defined connectors (vertical-connectors.md §7.2), the dashboard's door.
4805
+ if (parsed.connectors)
4806
+ installSpec.connectors = parsed.connectors;
4795
4807
  // Declared provisioner intent (#455) — the request half of the tenant-provisioner
4796
4808
  // capability; the grant is its own column, never part of this refreshable bag.
4797
4809
  if (parsed.provisions)
@@ -4989,6 +5001,7 @@ export class CloudflareScopeHost {
4989
5001
  ack,
4990
5002
  channel,
4991
5003
  baseId: outgoing?.id ?? null,
5004
+ permissionsChange: outgoing !== undefined && outgoing.permission_digest !== incoming.permission_digest,
4992
5005
  migrationsChange: outgoing !== undefined && outgoing.migration_digest !== incoming.migration_digest,
4993
5006
  read: async () => ({ base: outgoing ? await reviewed(outgoing) : null, incoming: await reviewed(incoming) }),
4994
5007
  });
@@ -5207,11 +5220,14 @@ export class CloudflareScopeHost {
5207
5220
  migrationTag: r.serving_migration_tag,
5208
5221
  });
5209
5222
  },
5210
- setVerticalServing: async (actor, verticalSlug, state) => {
5223
+ setVerticalServing: async (actor, verticalSlug, state, expected) => {
5211
5224
  const parsed = verticalServingState.parse(state);
5212
5225
  const r = await this.cp.readVertical(verticalSlug);
5213
5226
  if (!r)
5214
5227
  throw substratError('not_found', `unknown vertical '${verticalSlug}'`);
5228
+ // #2577: the caller's upload diffed its DO-class migration from `expected`'s class set and tag.
5229
+ if (expected !== undefined && !migratesFrom(r, expected))
5230
+ throw servingRaceRefusal(verticalSlug);
5215
5231
  // #2178: every install on the serving script runs the new version from this moment.
5216
5232
  if (r.serving_version_id) {
5217
5233
  const incoming = await this.cp.readVersion(parsed.versionId);
@@ -5219,14 +5235,15 @@ export class CloudflareScopeHost {
5219
5235
  await this.assertNoStrandFrom([r.serving_version_id], incoming.id, incoming, r.serving_ref ? await this.cp.provisionedVersionsOf(verticalSlug, { ref: r.serving_ref, versionId: r.serving_version_id }, null) : []);
5220
5236
  }
5221
5237
  }
5222
- // #2528: a compare-and-set on the serving ref and version the gate above judged, across
5223
- // awaits another serve can run in. The DO fills the row's `before` from the state it moves.
5238
+ // #2528/#2577: a compare-and-set on the serving state the gate above judged, its class set
5239
+ // and tag included, across awaits another serve can run in. The DO fills the row's `before`
5240
+ // from the state it moves.
5224
5241
  const moved = await this.directory('setVerticalServing', verticalSlug, {
5225
5242
  ref: parsed.ref,
5226
5243
  versionId: parsed.versionId,
5227
5244
  doClassesJson: JSON.stringify(parsed.doClasses),
5228
5245
  migrationTag: parsed.migrationTag,
5229
- }, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }), { ref: r.serving_ref ?? null, versionId: r.serving_version_id ?? null });
5246
+ }, this.adminEntry(actor, 'setVerticalServing', { tenantId: null }, null, { vertical: verticalSlug, ref: parsed.ref, versionId: parsed.versionId }), servedFromRow(r));
5230
5247
  if (!moved)
5231
5248
  throw servingRaceRefusal(verticalSlug);
5232
5249
  },
@@ -5491,9 +5508,7 @@ export class CloudflareScopeHost {
5491
5508
  // -- store-driver releases, rollouts and holds (D-63, #2300) -------------------
5492
5509
  recordStoreDriverRelease: async (actor, input) => {
5493
5510
  const at = new Date().toISOString();
5494
- const { record, created } = await this.directory('recordStoreDriverRelease', { ...input, actor, at });
5495
- if (created)
5496
- await this.recordAdmin(actor, 'uploadStoreDriverRelease', { tenantId: null }, null, record);
5511
+ const { record } = await this.directory('recordStoreDriverReleaseAndAudit', { ...input, actor, at }, this.adminEntry(actor, 'uploadStoreDriverRelease', { tenantId: null }, null, null));
5497
5512
  return record;
5498
5513
  },
5499
5514
  getStoreDriverRelease: async (actor, driver, sha256) => {
@@ -5526,13 +5541,11 @@ export class CloudflareScopeHost {
5526
5541
  await this.cp.releaseStoreDriverHold(input);
5527
5542
  },
5528
5543
  beginStoreDriverRollout: async (actor, input) => {
5529
- const row = await this.directory('beginStoreDriverRollout', { ...input, actor, at: new Date().toISOString() });
5530
- await this.recordAdmin(actor, input.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row);
5544
+ const row = await this.directory('beginStoreDriverRolloutAndAudit', { ...input, actor, at: new Date().toISOString() }, this.adminEntry(actor, input.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, null));
5531
5545
  return row;
5532
5546
  },
5533
5547
  finishStoreDriverRollout: async (actor, input) => {
5534
- const row = await this.directory('finishStoreDriverRollout', { ...input, at: new Date().toISOString() });
5535
- await this.recordAdmin(actor, row.action === 'rollback' ? 'rollBackStoreDriver' : 'rollOutStoreDriver', { tenantId: null }, null, row);
5548
+ const row = await this.directory('finishStoreDriverRolloutAndAudit', { ...input, at: new Date().toISOString() }, this.adminEntry(actor, 'rollOutStoreDriver', { tenantId: null }, null, null));
5536
5549
  return row;
5537
5550
  },
5538
5551
  storeDriverRebuild: async (actor, driver) => {
@@ -5831,8 +5844,7 @@ export class CloudflareScopeHost {
5831
5844
  // admin log too, so an entry written first would be overwritten by the very act
5832
5845
  // it records, leaving the platform's most consequential write invisible.
5833
5846
  const before = (await this.cp.listTenants({ limit: 1000 })).length;
5834
- await this.cp.importDump(dump.tables);
5835
- await this.recordAdmin(actor, 'restoreDirectory', { tenantId: null }, { tenants: before }, { capturedAt: dump.capturedAt, tables: dump.tables.length });
5847
+ await this.directory('importDumpAndAudit', dump.tables, this.adminEntry(actor, 'restoreDirectory', { tenantId: null }, { tenants: before }, { capturedAt: dump.capturedAt, tables: dump.tables.length }));
5836
5848
  },
5837
5849
  activateScope: async (actor, tenantId, scopeId) => {
5838
5850
  // Idempotent on `active`, unaudited because nothing changed. Provisioning is
@@ -6060,11 +6072,10 @@ export class CloudflareScopeHost {
6060
6072
  // anyway leaves the admin log recording an access that could not happen.
6061
6073
  await this.assertScope(input.tenantId, input.scopeId);
6062
6074
  const session = newImpersonationSession(ulid(), actor, input, new Date().toISOString());
6063
- await this.cp.writeImpersonation(impersonationRowValues(session));
6075
+ await this.directory('writeImpersonationAndAudit', impersonationRowValues(session), this.adminEntry(actor, 'beginImpersonation', { tenantId: session.tenantId, scopeId: session.scopeId }, null, session));
6064
6076
  // BEFORE the session is usable, so the log entry precedes every row it
6065
6077
  // will ever touch. The reason rides in `after`: it is what a review reads
6066
6078
  // first, and a log saying a session opened but not why is half a record.
6067
- await this.recordAdmin(actor, 'beginImpersonation', { tenantId: session.tenantId, scopeId: session.scopeId }, null, session);
6068
6079
  return session;
6069
6080
  },
6070
6081
  endImpersonation: async (actor, id) => {
@@ -6076,9 +6087,9 @@ export class CloudflareScopeHost {
6076
6087
  // already stopped it.
6077
6088
  if (before.endedAt !== null)
6078
6089
  return before;
6079
- await this.cp.endImpersonation(String(id), new Date().toISOString());
6090
+ const endedAt = new Date().toISOString();
6091
+ await this.directory('endImpersonationAndAudit', String(id), endedAt, this.adminEntry(actor, 'endImpersonation', { tenantId: before.tenantId, scopeId: before.scopeId }, before, { ...before, endedAt }));
6080
6092
  const after = mapImpersonationRow((await this.cp.readImpersonation(String(id))));
6081
- await this.recordAdmin(actor, 'endImpersonation', { tenantId: after.tenantId, scopeId: after.scopeId }, before, after);
6082
6093
  return after;
6083
6094
  },
6084
6095
  listImpersonations: async (actor, filter) => {
@@ -6158,12 +6169,11 @@ export class CloudflareScopeHost {
6158
6169
  },
6159
6170
  registerIdentityPool: async (actor, input) => {
6160
6171
  const parsed = identityPool.parse(input);
6161
- const created = await this.directory('registerIdentityPool', parsed.provider, parsed.topology, parsed.tenantId, new Date().toISOString());
6172
+ const created = await this.directory('registerIdentityPoolAndAudit', parsed.provider, parsed.topology, parsed.tenantId, new Date().toISOString(), this.adminEntry(actor, 'registerIdentityPool', { tenantId: parsed.tenantId }, null, parsed));
6162
6173
  if (!created)
6163
6174
  return; // identical registration is idempotent, unaudited
6164
6175
  // Null tenant for a central pool: it belongs to no single tenant, which is
6165
6176
  // what made the admin log's tenantId nullable.
6166
- await this.recordAdmin(actor, 'registerIdentityPool', { tenantId: parsed.tenantId }, null, parsed);
6167
6177
  },
6168
6178
  getIdentityPool: async (actor, provider) => {
6169
6179
  const r = await this.cp.readPool(provider);
@@ -6220,7 +6230,7 @@ export class CloudflareScopeHost {
6220
6230
  // never seen a plaintext credential.
6221
6231
  const sealed = await this.secretBox.seal(JSON.stringify(input.secret));
6222
6232
  const now = new Date().toISOString();
6223
- await this.cp.insertConnection({
6233
+ await this.directory('insertConnectionAndAudit', {
6224
6234
  id: input.id,
6225
6235
  tenantId: input.tenantId,
6226
6236
  vertical: input.vertical,
@@ -6235,17 +6245,16 @@ export class CloudflareScopeHost {
6235
6245
  createdAt: now,
6236
6246
  keyId: sealed.keyId,
6237
6247
  ciphertext: sealed.ciphertext,
6238
- });
6239
- // METADATA ONLY — the admin log is append-only, so a credential written
6240
- // here could never be removed.
6241
- await this.recordAdmin(actor, 'createConnection', { tenantId: input.tenantId, vertical: input.vertical }, null, {
6248
+ }, this.adminEntry(actor, 'createConnection', { tenantId: input.tenantId, vertical: input.vertical }, null, {
6242
6249
  id: input.id,
6243
6250
  provider: input.provider,
6244
6251
  label: input.label,
6245
6252
  scopes: input.scopes,
6246
6253
  externalAccountRef: input.externalAccountRef ?? null,
6247
6254
  createdBy: input.createdBy ?? actor,
6248
- });
6255
+ }));
6256
+ // METADATA ONLY — the admin log is append-only, so a credential written
6257
+ // here could never be removed.
6249
6258
  },
6250
6259
  connectionSealingKey: async (id) => this.ensureSealingKey(id),
6251
6260
  connectionSealingKeys: async (tenantId, vertical) => {
@@ -6288,26 +6297,22 @@ export class CloudflareScopeHost {
6288
6297
  throw new Error(`connection not found: ${id}`);
6289
6298
  const sealed = await this.secretBox.seal(JSON.stringify(connectionSecret.parse(secret)));
6290
6299
  const now = new Date().toISOString();
6291
- await this.cp.updateConnectionSecret(id, sealed.keyId, sealed.ciphertext, expiresAt === undefined ? row.expires_at : expiresAt, now);
6292
- await this.recordAdmin(actor, 'updateConnectionSecret', { tenantId: row.tenant_id, vertical: row.vertical }, null, {
6300
+ await this.directory('updateConnectionSecretAndAudit', id, sealed.keyId, sealed.ciphertext, expiresAt === undefined ? row.expires_at : expiresAt, now, this.adminEntry(actor, 'updateConnectionSecret', { tenantId: row.tenant_id, vertical: row.vertical }, null, {
6293
6301
  id,
6294
6302
  provider: row.provider,
6295
6303
  rotatedAt: now,
6296
6304
  expiresAt: expiresAt === undefined ? row.expires_at : expiresAt,
6297
- // §3.5.1's attribution, rotate-side: the authorizing tenant principal,
6298
- // never laundered into the actor column.
6299
6305
  ...(opts?.rotatedBy ? { rotatedBy: opts.rotatedBy } : {}),
6300
- });
6306
+ }));
6301
6307
  },
6302
6308
  revokeConnection: async (actor, id) => {
6303
6309
  const row = await this.cp.readConnection(id);
6304
6310
  if (!row)
6305
6311
  throw new Error(`connection not found: ${id}`);
6306
6312
  const now = new Date().toISOString();
6307
- const changed = await this.cp.revokeConnection(id, now);
6313
+ const changed = await this.directory('revokeConnectionAndAudit', id, now, this.adminEntry(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now }));
6308
6314
  if (!changed)
6309
6315
  return; // idempotent, and a no-op is not audited
6310
- await this.recordAdmin(actor, 'revokeConnection', { tenantId: row.tenant_id, vertical: row.vertical }, { status: row.status }, { id, provider: row.provider, status: 'revoked', revokedAt: now });
6311
6316
  },
6312
6317
  // -- a vertical's mailed connect links (connections.md §3.5.4) --------------
6313
6318
  // Parsed here, run in the ControlPlaneDO as the kernel's statements (shared with
@@ -6405,11 +6410,10 @@ export class CloudflareScopeHost {
6405
6410
  if (pool.topology === 'tenant-bound' && pool.tenant_id !== parsed.tenantId) {
6406
6411
  throw substratError('conflict', `identity pool '${parsed.provider}' is bound to tenant ${pool.tenant_id} and cannot link into ${parsed.tenantId}`);
6407
6412
  }
6408
- const changed = await this.directory('linkIdentity', parsed.provider, parsed.externalId, parsed.principal, parsed.tenantId, parsed.scopeId ?? null, new Date().toISOString());
6413
+ const changed = await this.directory('linkIdentityAndAudit', parsed.provider, parsed.externalId, parsed.principal, parsed.tenantId, parsed.scopeId ?? null, new Date().toISOString(), this.adminEntry(actor, 'linkIdentity', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, { provider: parsed.provider, externalId: parsed.externalId, principal: parsed.principal }));
6409
6414
  // Idempotent: an identity already bound is a no-op, not audited.
6410
6415
  if (!changed)
6411
6416
  return;
6412
- await this.recordAdmin(actor, 'linkIdentity', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, { provider: parsed.provider, externalId: parsed.externalId, principal: parsed.principal });
6413
6417
  // #406: an identity link is a tenant-level write, so it fans out into the tenant's
6414
6418
  // projected scopes exactly as entitlements do (#304) — a no-op unless scope-local
6415
6419
  // projection is on; the reconcile sweep repairs any drop.
@@ -6418,10 +6422,9 @@ export class CloudflareScopeHost {
6418
6422
  unlinkIdentity: async (actor, tenantId, principal) => {
6419
6423
  // DELETE by principal (audit is the log), so the caller who removed a member
6420
6424
  // can sever their login without knowing the external subject. Idempotent.
6421
- const changed = await this.cp.unlinkIdentity(tenantId, principal);
6425
+ const changed = await this.directory('unlinkIdentityAndAudit', tenantId, principal, this.adminEntry(actor, 'unlinkIdentity', { tenantId, scopeId: null }, { principal }, null));
6422
6426
  if (!changed)
6423
6427
  return;
6424
- await this.recordAdmin(actor, 'unlinkIdentity', { tenantId, scopeId: null }, { principal }, null);
6425
6428
  // The unlink must reach the projected scopes so the severed login stops resolving
6426
6429
  // there — this is what makes revocation durable at request time (#406); a dropped
6427
6430
  // fan-out is repaired by the reconcile sweep.
@@ -6513,24 +6516,11 @@ export class CloudflareScopeHost {
6513
6516
  }));
6514
6517
  },
6515
6518
  markAccessLogDrained: async (actor, upToId, drainedAt) => {
6516
- const drained = await this.cp.markAccessLogDrained(upToId, drainedAt);
6517
- if (drained > 0) {
6518
- // The payload is the APPLIED state, so it belongs in `after` (contracts'
6519
- // adminLogEntry: before = prior state, after = the applied payload).
6520
- await this.recordAdmin(actor, 'drainAccessLog', { tenantId: null }, null, { drained, upToId, drainedAt });
6521
- }
6522
- return drained;
6519
+ return this.directory('markAccessLogDrainedAndAudit', upToId, drainedAt, this.adminEntry(actor, 'drainAccessLog', { tenantId: null }, null, null));
6523
6520
  },
6524
6521
  pruneAccessLog: async (actor, limit) => {
6525
6522
  // Checked here as well as in the directory, so the refusal keeps its code across the hop.
6526
- const pruned = await this.cp.pruneAccessLog(assertRowLimit('limit', limit));
6527
- if (pruned > 0) {
6528
- // The payload is the APPLIED state, so it belongs in `after` (contracts'
6529
- // adminLogEntry: before = prior state, after = the applied payload) — the
6530
- // same shape as drainAccessLog's row above (#557).
6531
- await this.recordAdmin(actor, 'pruneAccessLog', { tenantId: null }, null, { pruned });
6532
- }
6533
- return pruned;
6523
+ return this.directory('pruneAccessLogAndAudit', assertRowLimit('limit', limit), this.adminEntry(actor, 'pruneAccessLog', { tenantId: null }, null, null));
6534
6524
  },
6535
6525
  auditLog: async (actor, filter) => {
6536
6526
  const rows = await this.cp.auditLog({
@@ -6656,13 +6646,11 @@ export class CloudflareScopeHost {
6656
6646
  return rows.map((r) => issueEntry.parse(r));
6657
6647
  },
6658
6648
  setIssueStatus: async (actor, fingerprint, status) => {
6659
- const change = await this.cp.setIssueStatus(fingerprint, status, new Date().toISOString());
6649
+ const change = await this.directory('setIssueStatusAndAudit', fingerprint, status, new Date().toISOString(), this.adminEntry(actor, 'setIssueStatus', { tenantId: null }, null, null));
6660
6650
  if (!change)
6661
6651
  return undefined;
6662
- const before = issueEntry.parse(change.before);
6663
6652
  const after = issueEntry.parse(change.after);
6664
- // A lifecycle flip is a staff mutation — audited with the diff (K-33).
6665
- await this.recordAdmin(actor, 'setIssueStatus', { tenantId: null, vertical: after.lastVertical }, { fingerprint, status: before.status }, { fingerprint, status: after.status });
6653
+ // A lifecycle flip is recorded in the same directory unit as the state write.
6666
6654
  return after;
6667
6655
  },
6668
6656
  listFindings: async (actor, filter) => {
@@ -7230,12 +7218,12 @@ export class CloudflareScopeHost {
7230
7218
  async switchHeld(scopeId, holdKey) {
7231
7219
  const at = Date.now();
7232
7220
  let error;
7233
- if (!this.holdSnapshot || at - this.holdSnapshot.at > SWITCH_HOLD_SNAPSHOT_MS) {
7221
+ if (!this.holdSnapshot || at - this.holdSnapshot.at > this.holdSnapshotMs) {
7234
7222
  const inflight = this.holdRefresh;
7235
7223
  // Join a read that went out within the snapshot age of this consult: its result is then as
7236
7224
  // fresh as one this consult would take itself, which is what the settle argument needs.
7237
7225
  // Only the consult that sent the read reports its failure.
7238
- if (inflight && at - inflight.at <= SWITCH_HOLD_SNAPSHOT_MS) {
7226
+ if (inflight && at - inflight.at <= this.holdSnapshotMs) {
7239
7227
  await inflight.done;
7240
7228
  }
7241
7229
  else {
@@ -7477,7 +7465,7 @@ export class CloudflareScopeHost {
7477
7465
  * claimed by the re-read.
7478
7466
  */
7479
7467
  async settleClaim(holds, scopeId, claimId) {
7480
- await new Promise((resolve) => setTimeout(resolve, SWITCH_HOLD_SETTLE_MS));
7468
+ await new Promise((resolve) => setTimeout(resolve, this.holdSettleMs));
7481
7469
  for (let waits = 0;; waits++) {
7482
7470
  const token = await holds.switchHoldToken();
7483
7471
  const off = await this.offInStorage(scopeId);
@@ -7486,14 +7474,14 @@ export class CloudflareScopeHost {
7486
7474
  this.holdSnapshot = null;
7487
7475
  }
7488
7476
  const youngest = await holds.switchHoldYoungestMs(scopeId, claimId);
7489
- if (youngest === null || youngest >= SWITCH_HOLD_SETTLE_MS)
7477
+ if (youngest === null || youngest >= this.holdSettleMs)
7490
7478
  return;
7491
7479
  if (waits === SWITCH_HOLD_EXTRA_WAITS) {
7492
7480
  throw new Error(`${REWIND_REFUSED}schedule switches kept being pulled off while this rewind waited for them to ` +
7493
7481
  `settle (${waits} extra waits); nothing was rewound, and those switches stay off. Retry the rewind`);
7494
7482
  }
7495
7483
  // Never longer than one settle, even if the hold object's clock moved back under a restart.
7496
- await new Promise((resolve) => setTimeout(resolve, SWITCH_HOLD_SETTLE_MS - Math.max(0, youngest)));
7484
+ await new Promise((resolve) => setTimeout(resolve, this.holdSettleMs - Math.max(0, youngest)));
7497
7485
  }
7498
7486
  }
7499
7487
  // -- live reads (#938): the door ------------------------------------------
@@ -8673,6 +8661,18 @@ export class CloudflareScopeHost {
8673
8661
  }
8674
8662
  return connectionLandingsOfManifestJson(v.manifest_json);
8675
8663
  }
8664
+ /**
8665
+ * What one pushed version declares about vertical-defined connectors (vertical-connectors.md
8666
+ * §6.4), from its stored manifest: the connector proxy reads it for the running version on every
8667
+ * call. Unaudited for the reason {@link versionImports} is.
8668
+ */
8669
+ async versionConnectors(verticalSlug, versionId) {
8670
+ const v = await this.cp.readVersion(versionId);
8671
+ if (!v || v.vertical_slug !== verticalSlug) {
8672
+ throw substratError('not_found', `unknown version ${versionId} for vertical '${verticalSlug}'`);
8673
+ }
8674
+ return connectorsOfManifestJson(v.manifest_json);
8675
+ }
8676
8676
  }
8677
8677
  /** A ledger row -> the wire entry, the attribution re-nested (#1054). */
8678
8678
  function modelUsageEntryOf(r) {